feat: improve epub reader controls and annotations

This commit is contained in:
shen 2026-07-12 12:23:10 +08:00
parent 063a493b18
commit 8ccb7157b2
36 changed files with 2959 additions and 2160 deletions

File diff suppressed because it is too large Load Diff

View File

@ -54,7 +54,6 @@ enum IDs {
static let settingsFontValue = "epub.reader.settings.font.value" static let settingsFontValue = "epub.reader.settings.font.value"
static let settingsFontChoice = "epub.reader.settings.font.choice" static let settingsFontChoice = "epub.reader.settings.font.choice"
static let settingsLineHeight = "epub.reader.settings.lineHeight" static let settingsLineHeight = "epub.reader.settings.lineHeight"
static let settingsColumns = "epub.reader.settings.columns"
static let settingsDisplayType = "epub.reader.settings.displayType" static let settingsDisplayType = "epub.reader.settings.displayType"
static let settingsDone = "epub.reader.settings.done" static let settingsDone = "epub.reader.settings.done"
static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" } static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" }

View File

@ -28,6 +28,7 @@ struct DemoReaderState {
var isOpened: Bool { fields["reader"] == "opened" } var isOpened: Bool { fields["reader"] == "opened" }
var page: Int? { fields["page"].flatMap(Int.init) } var page: Int? { fields["page"].flatMap(Int.init) }
var pagesPerScreen: Int? { fields["pagesPerScreen"].flatMap(Int.init) }
var display: String? { fields["display"] } var display: String? { fields["display"] }
var toolbar: String? { fields["toolbar"] } var toolbar: String? { fields["toolbar"] }
var highlights: Int? { fields["highlights"].flatMap(Int.init) } var highlights: Int? { fields["highlights"].flatMap(Int.init) }

View File

@ -23,6 +23,11 @@ final class FixedLayoutRotationTests: XCTestCase {
for _ in 0..<3 { for _ in 0..<3 {
XCUIDevice.shared.orientation = .landscapeLeft XCUIDevice.shared.orientation = .landscapeLeft
RunLoop.current.run(until: Date().addingTimeInterval(2)) RunLoop.current.run(until: Date().addingTimeInterval(2))
let landscapeState = app.waitForDemoReaderState(
timeout: 5,
description: "固定版式横屏保持单页"
) { $0.pagesPerScreen == 1 }
XCTAssertEqual(landscapeState.pagesPerScreen, 1, "交互型固定版式书横屏不应启用双页")
XCUIDevice.shared.orientation = .portrait XCUIDevice.shared.orientation = .portrait
RunLoop.current.run(until: Date().addingTimeInterval(2)) RunLoop.current.run(until: Date().addingTimeInterval(2))
} }
@ -30,4 +35,29 @@ final class FixedLayoutRotationTests: XCTestCase {
XCTAssertEqual(app.state, .runningForeground, "反复旋转后 app 已退出(疑似崩溃)") XCTAssertEqual(app.state, .runningForeground, "反复旋转后 app 已退出(疑似崩溃)")
app.waitForReader() app.waitForReader()
} }
func testFixedLayoutCachedPageCanReturnAfterPageTurn() {
app.launchAndOpenSampleBook(bookTitleQuery: "熊爷爷")
app.waitForReader()
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5), "固定版式阅读区域未出现")
let initialState = app.waitForDemoReaderState(timeout: 5, description: "固定版式初始页") {
$0.page != nil
}
let initialPage = initialState.page ?? 0
content.swipeLeft()
let nextState = app.waitForDemoReaderState(timeout: 8, description: "固定版式向后翻页") { state in
guard let page = state.page else { return false }
return page != initialPage
}
content.swipeRight()
let returnedState = app.waitForDemoReaderState(timeout: 8, description: "固定版式返回缓存页") {
$0.page == initialPage
}
XCTAssertNotEqual(nextState.page, returnedState.page, "固定版式返回缓存页后页码未恢复")
XCTAssertTrue(content.exists, "返回缓存页后阅读区域不应消失")
}
} }

View File

@ -19,9 +19,9 @@ final class HighlightsManagementTests: XCTestCase {
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5)) let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
let highlightMenuItem = app.menuItems["高亮"] let highlightMenuItem = app.menuItems["划线"]
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() } if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() } else if app.buttons["划线"].waitForExistence(timeout: 2) { app.buttons["划线"].tap() }
app.waitForDemoReaderState(timeout: 5, description: "highlights exists") { $0.highlights != nil } app.waitForDemoReaderState(timeout: 5, description: "highlights exists") { $0.highlights != nil }
@ -42,7 +42,7 @@ final class HighlightsManagementTests: XCTestCase {
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5)) let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
let highlightMenuItem = app.menuItems["高亮"] let highlightMenuItem = app.menuItems["划线"]
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() } if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
@ -63,7 +63,7 @@ final class HighlightsManagementTests: XCTestCase {
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5)) let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
let highlightMenuItem = app.menuItems["高亮"] let highlightMenuItem = app.menuItems["划线"]
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() } if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
@ -102,7 +102,7 @@ final class HighlightsManagementTests: XCTestCase {
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5)) let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
let highlightMenuItem = app.menuItems["高亮"] let highlightMenuItem = app.menuItems["划线"]
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() } if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
@ -112,15 +112,14 @@ final class HighlightsManagementTests: XCTestCase {
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5)) XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5))
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5)) XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5))
highlightsTable.cells.element(boundBy: 0).tap() let editButton = app.navigationBars.buttons["编辑"].firstMatch
XCTAssertTrue(editButton.waitForExistence(timeout: 3), "标注列表应提供编辑入口")
editButton.tap()
let deleteButton = app.buttons["删除标注"].firstMatch let deleteControl = highlightsTable.buttons["删除"].firstMatch
let deleteHighlightButton = app.buttons["删除高亮"].firstMatch XCTAssertTrue(deleteControl.waitForExistence(timeout: 3), "编辑状态应显示删除控件")
if deleteButton.waitForExistence(timeout: 3) { deleteControl.tap()
deleteButton.tap() app.buttons["删除"].lastMatch.tap()
} else if deleteHighlightButton.waitForExistence(timeout: 1) {
deleteHighlightButton.tap()
}
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态") XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
} }

View File

@ -136,8 +136,8 @@ final class LocationPersistenceTests: XCTestCase {
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)) let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5)) let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
XCTAssertTrue(app.menuItems["高亮"].waitForExistence(timeout: 3)) XCTAssertTrue(app.menuItems["划线"].waitForExistence(timeout: 3))
app.menuItems["高亮"].tap() app.menuItems["划线"].tap()
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 } app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
@ -167,7 +167,7 @@ final class LocationPersistenceTests: XCTestCase {
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5)) let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
let highlightItem = app.menuItems["高亮"] let highlightItem = app.menuItems["划线"]
XCTAssertTrue(highlightItem.waitForExistence(timeout: 3), "应出现高亮菜单") XCTAssertTrue(highlightItem.waitForExistence(timeout: 3), "应出现高亮菜单")
highlightItem.tap() highlightItem.tap()
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 } app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }

View File

@ -22,7 +22,7 @@ final class ReaderAnnotationTests: XCTestCase {
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 } app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
let highlightMenuItem = app.menuItems["高亮"] let highlightMenuItem = app.menuItems["划线"]
let highlightButton = app.buttons[IDs.readerSelectionHighlight] let highlightButton = app.buttons[IDs.readerSelectionHighlight]
if highlightMenuItem.waitForExistence(timeout: 3) { if highlightMenuItem.waitForExistence(timeout: 3) {
highlightMenuItem.tap() highlightMenuItem.tap()
@ -65,7 +65,7 @@ final class ReaderAnnotationTests: XCTestCase {
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 } app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
let annotateMenuItem = app.menuItems[""] let annotateMenuItem = app.menuItems[""]
XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 3), "选中文本后未出现批注菜单") XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 3), "选中文本后未出现批注菜单")
annotateMenuItem.tap() annotateMenuItem.tap()

View File

@ -29,7 +29,7 @@ final class SelectionAnnotateTests: XCTestCase {
Thread.sleep(forTimeInterval: 0.5) Thread.sleep(forTimeInterval: 0.5)
// //
let annotateItem = app.menuItems[""] let annotateItem = app.menuItems[""]
guard annotateItem.waitForExistence(timeout: 3) else { guard annotateItem.waitForExistence(timeout: 3) else {
throw XCTSkip("批注菜单项不存在") throw XCTSkip("批注菜单项不存在")
} }
@ -56,7 +56,7 @@ final class SelectionAnnotateTests: XCTestCase {
Thread.sleep(forTimeInterval: 0.5) Thread.sleep(forTimeInterval: 0.5)
let annotateItem = app.menuItems[""] let annotateItem = app.menuItems[""]
guard annotateItem.waitForExistence(timeout: 3) else { guard annotateItem.waitForExistence(timeout: 3) else {
throw XCTSkip("批注菜单项不存在") throw XCTSkip("批注菜单项不存在")
} }
@ -101,7 +101,7 @@ final class SelectionAnnotateTests: XCTestCase {
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
Thread.sleep(forTimeInterval: 0.5) Thread.sleep(forTimeInterval: 0.5)
let highlightItem = app.menuItems["高亮"] let highlightItem = app.menuItems["划线"]
guard highlightItem.waitForExistence(timeout: 3) else { guard highlightItem.waitForExistence(timeout: 3) else {
throw XCTSkip("高亮菜单项不存在") throw XCTSkip("高亮菜单项不存在")
} }

View File

@ -18,7 +18,7 @@ final class SelectionMenuTests: XCTestCase {
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5)) let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项") XCTAssertTrue(app.menuItems["复制"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
} }
func testSelectionMenuShowsAnnotateOption() throws { func testSelectionMenuShowsAnnotateOption() throws {
@ -32,7 +32,7 @@ final class SelectionMenuTests: XCTestCase {
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5)) let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
XCTAssertTrue(app.menuItems[""].waitForExistence(timeout: 3), "选区菜单应包含批注选项") XCTAssertTrue(app.menuItems[""].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
} }
func testTapBlankAreaClearsSelection() throws { func testTapBlankAreaClearsSelection() throws {
@ -46,14 +46,14 @@ final class SelectionMenuTests: XCTestCase {
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5)) let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应出现") XCTAssertTrue(app.menuItems["复制"].waitForExistence(timeout: 3), "选区菜单应出现")
let contentArea = app.otherElements[IDs.readerContentView].firstMatch let contentArea = app.otherElements[IDs.readerContentView].firstMatch
if contentArea.waitForExistence(timeout: 3) { if contentArea.waitForExistence(timeout: 3) {
contentArea.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap() contentArea.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
} }
let menuGone = !app.menuItems["拷贝"].waitForExistence(timeout: 2) let menuGone = !app.menuItems["复制"].waitForExistence(timeout: 2)
XCTAssertTrue(menuGone, "点击空白区域后选区菜单应消失") XCTAssertTrue(menuGone, "点击空白区域后选区菜单应消失")
} }
@ -68,7 +68,7 @@ final class SelectionMenuTests: XCTestCase {
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5)) let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
let copyMenuItem = app.menuItems["拷贝"] let copyMenuItem = app.menuItems["复制"]
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() } if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened } app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }

View File

@ -44,24 +44,6 @@ final class SettingsEffectTests: XCTestCase {
XCTAssertTrue(contentView.waitForExistence(timeout: 5), "主题切换后内容区应存在") XCTAssertTrue(contentView.waitForExistence(timeout: 5), "主题切换后内容区应存在")
} }
func testColumnCountChangeUpdatesLayout() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
app.scrollViews[IDs.settingsScroll].swipeUp()
let columnsControl = app.segmentedControls[IDs.settingsColumns]
if columnsControl.waitForExistence(timeout: 3) && columnsControl.buttons.count > 1 {
columnsControl.buttons.element(boundBy: 1).tap()
}
app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
}
func testLineHeightChangeUpdatesContent() throws { func testLineHeightChangeUpdatesContent() throws {
app.launchAndOpenSampleBook() app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)

View File

@ -36,18 +36,6 @@ final class SettingsExtendedTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
} }
func testColumnCountControl() {
openSettings()
let columns = app.segmentedControls[IDs.settingsColumns]
XCTAssertTrue(columns.waitForExistence(timeout: 3), "分栏控件未出现")
columns.buttons["单栏"].tap()
columns.buttons["双栏"].tap()
app.buttons[IDs.settingsDone].tap()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
}
func testDisplayTypeFromSettings() { func testDisplayTypeFromSettings() {
openSettings() openSettings()
let displayType = app.segmentedControls[IDs.settingsDisplayType] let displayType = app.segmentedControls[IDs.settingsDisplayType]
@ -73,7 +61,6 @@ final class SettingsExtendedTests: XCTestCase {
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3)) XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3)) XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsColumns].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3)) XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3))
} }
} }

View File

@ -110,6 +110,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
public var note: String? public var note: String?
/// True when this record was created directly as an annotation instead of
/// adding a note to an existing underline.
public var isAnnotationOnly: Bool
public var createdAt: Date public var createdAt: Date
public init( public init(
@ -118,9 +122,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
location: RDEPUBLocation, location: RDEPUBLocation,
text: String, text: String,
rangeInfo: String? = nil, rangeInfo: String? = nil,
style: RDEPUBHighlightStyle = .highlight, style: RDEPUBHighlightStyle = .underline,
color: String = "#F8E16C", color: String = "#F8E16C",
note: String? = nil, note: String? = nil,
isAnnotationOnly: Bool = false,
createdAt: Date = Date() createdAt: Date = Date()
) { ) {
self.id = id self.id = id
@ -131,6 +136,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
self.style = style self.style = style
self.color = color self.color = color
self.note = note?.nilIfEmpty self.note = note?.nilIfEmpty
self.isAnnotationOnly = isAnnotationOnly
self.createdAt = createdAt self.createdAt = createdAt
} }
@ -152,6 +158,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
case style case style
case color case color
case note case note
case isAnnotationOnly
case createdAt case createdAt
} }
@ -164,12 +171,13 @@ public struct RDEPUBHighlight: Codable, Equatable {
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style), if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) { let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
style = decodedStyle style = decodedStyle == .highlight ? .underline : decodedStyle
} else { } else {
style = .highlight style = .underline
} }
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C" color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
isAnnotationOnly = try container.decodeIfPresent(Bool.self, forKey: .isAnnotationOnly) ?? false
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
} }
@ -183,6 +191,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
try container.encode(style, forKey: .style) try container.encode(style, forKey: .style)
try container.encode(color, forKey: .color) try container.encode(color, forKey: .color)
try container.encodeIfPresent(note, forKey: .note) try container.encodeIfPresent(note, forKey: .note)
try container.encode(isAnnotationOnly, forKey: .isAnnotationOnly)
try container.encode(createdAt, forKey: .createdAt) try container.encode(createdAt, forKey: .createdAt)
} }

View File

@ -36,6 +36,13 @@ public final class RDEPUBPublication {
parser.readingProfile() parser.readingProfile()
} }
/// Scripted fixed-layout publications own a complete interactive scene per
/// spine item. Combining scenes in landscape can start multiple timelines
/// and media tracks, so these publications remain single-page.
public lazy var requiresSinglePagePresentation: Bool = {
layout == .fixed && parser.hasInteractiveContent()
}()
public var readingProgression: RDEPUBReadingProgression { public var readingProgression: RDEPUBReadingProgression {
metadata.readingProgression metadata.readingProgression
} }

View File

@ -56,9 +56,9 @@ extension RDEPUBWebView {
webView._editMenuInteraction = interaction webView._editMenuInteraction = interaction
} else { } else {
UIMenuController.shared.menuItems = [ UIMenuController.shared.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))), UIMenuItem(title: "复制", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))), UIMenuItem(title: "划线", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
UIMenuItem(title: "", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:))) UIMenuItem(title: "", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
] ]
} }
webView.navigationDelegate = self webView.navigationDelegate = self
@ -89,6 +89,11 @@ extension RDEPUBWebView {
self.schemeHandler = schemeHandler self.schemeHandler = schemeHandler
self.webView = webView self.webView = webView
self.configuredPublicationKey = publicationKey self.configuredPublicationKey = publicationKey
appliedNativeMediaPlaybackSuspended = false
isNativeMediaPlaybackOperationInFlight = false
nativeMediaPlaybackOperationGeneration &+= 1
nativeMediaPlaybackRequests.removeAll()
setNativeReaderPageMediaPlaybackSuspended(true)
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)") RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
} }
@ -103,6 +108,7 @@ extension RDEPUBWebView {
func teardownWebView() { func teardownWebView() {
cancelFixedLayoutReadyFallback() cancelFixedLayoutReadyFallback()
RDEPUBReaderMediaPlaybackCoordinator.shared.remove(self)
if let webView { if let webView {
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))") RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
} }
@ -119,6 +125,10 @@ extension RDEPUBWebView {
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil (webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
webView?.removeFromSuperview() webView?.removeFromSuperview()
webView = nil webView = nil
finishAllNativeMediaPlaybackRequests()
isWaitingForReaderPageMediaNavigation = false
pendingReaderPageMediaNavigation = nil
isReaderPageMediaActivationBlocked = false
schemeHandler = nil schemeHandler = nil
configuredPublicationKey = nil configuredPublicationKey = nil
currentLoadSignature = nil currentLoadSignature = nil
@ -143,11 +153,101 @@ private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHan
} }
} }
/// Serializes native media activation across page WebViews. WebKit suspension
/// is asynchronous and separate WKWebView instances have no ordering contract;
/// the incoming page therefore waits for the previous active page's suspension
/// completion before it can be resumed.
private final class RDEPUBReaderMediaPlaybackCoordinator {
static let shared = RDEPUBReaderMediaPlaybackCoordinator()
private weak var activePage: RDEPUBWebView?
private weak var pendingPage: RDEPUBWebView?
private var activationGeneration: UInt = 0
func setPlaybackSuspended(
_ suspended: Bool,
for page: RDEPUBWebView,
completion: @escaping () -> Void = {}
) {
if suspended {
suspend(page, completion: completion)
} else {
activate(page, completion: completion)
}
}
func remove(_ page: RDEPUBWebView) {
if pendingPage === page {
activationGeneration &+= 1
}
if activePage === page {
activePage = nil
}
if pendingPage === page {
pendingPage = nil
}
}
private func suspend(_ page: RDEPUBWebView, completion: @escaping () -> Void) {
if pendingPage === page || (activePage === page && pendingPage == nil) {
activationGeneration &+= 1
}
if pendingPage === page {
pendingPage = nil
}
let suspensionGeneration = activationGeneration
page.applyNativeMediaPlaybackSuspended(true) { [weak self, weak page] in
guard let self, let page else { return }
if self.activationGeneration == suspensionGeneration,
self.activePage === page {
self.activePage = nil
}
completion()
}
}
private func activate(_ page: RDEPUBWebView, completion: @escaping () -> Void) {
activationGeneration &+= 1
let generation = activationGeneration
pendingPage = page
let outgoingPage = activePage
let resumeIncomingPage = { [weak self, weak page] in
guard let self,
let page,
self.activationGeneration == generation,
self.pendingPage === page else { return }
self.pendingPage = nil
self.activePage = page
page.applyNativeMediaPlaybackSuspended(false) { [weak self, weak page] in
guard let self, let page else { return }
if self.activePage !== page {
page.applyNativeMediaPlaybackSuspended(true, completion: {})
} else {
completion()
}
}
}
guard let outgoingPage, outgoingPage !== page else {
resumeIncomingPage()
return
}
outgoingPage.applyNativeMediaPlaybackSuspended(true) {
resumeIncomingPage()
}
}
}
extension RDEPUBWebView { extension RDEPUBWebView {
/// Installed in every frame before EPUB scripts run. Hidden pages start in /// Installed in every frame before EPUB scripts run. Hidden pages start in
/// a suspended state, including audio created with `new Audio()` that is /// a suspended state for both HTML media and Web Audio. Tumult Hype uses
/// not attached to the DOM (as used by Tumult Hype publications). /// `AudioContext`/`AudioBufferSourceNode` for this publication, so handling
/// only `<audio>` and `new Audio()` leaves narration running on cached pages.
static let readerPageMediaLifecycleUserScript = #""" static let readerPageMediaLifecycleUserScript = #"""
(() => { (() => {
if (window.__rdReaderMediaLifecycleInstalled) return; if (window.__rdReaderMediaLifecycleInstalled) return;
@ -155,10 +255,17 @@ extension RDEPUBWebView {
let isActive = false; let isActive = false;
const trackedMedia = new Set(); const trackedMedia = new Set();
const trackedAudioContexts = new Set();
const lifecycleSuspendedAudioContexts = new Set();
const command = "rd-reader-page-visibility"; const command = "rd-reader-page-visibility";
const frameReadyCommand = "rd-reader-frame-media-ready";
const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype; const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype;
const nativePlay = mediaPrototype && mediaPrototype.play; const nativePlay = mediaPrototype && mediaPrototype.play;
const ignoreRejection = result => {
if (result && result.catch) result.catch(() => {});
};
const register = media => { const register = media => {
if (media) trackedMedia.add(media); if (media) trackedMedia.add(media);
return media; return media;
@ -169,17 +276,47 @@ extension RDEPUBWebView {
document.querySelectorAll("audio,video").forEach(register); document.querySelectorAll("audio,video").forEach(register);
}; };
const updateAudioContext = context => {
if (!context || context.state === "closed") return;
if (isActive) {
if (!lifecycleSuspendedAudioContexts.has(context)) return;
try {
const result = context.resume();
if (result && result.then) {
result.then(() => lifecycleSuspendedAudioContexts.delete(context)).catch(() => {});
} else {
lifecycleSuspendedAudioContexts.delete(context);
}
} catch (_) {}
return;
}
if (context.state !== "running" || lifecycleSuspendedAudioContexts.has(context)) return;
lifecycleSuspendedAudioContexts.add(context);
try { ignoreRejection(context.suspend()); } catch (_) {}
};
const registerAudioContext = context => {
if (context) {
trackedAudioContexts.add(context);
updateAudioContext(context);
}
return context;
};
const setActive = active => { const setActive = active => {
isActive = !!active; isActive = !!active;
collectDocumentMedia(); collectDocumentMedia();
trackedMedia.forEach(media => { trackedMedia.forEach(media => {
if (isActive) { if (isActive) {
if (media.__rdReaderReplayWhenVisible) { if (media.__rdReaderReplayWhenVisible) {
media.__rdReaderReplayWhenVisible = false;
try { try {
media.currentTime = 0; media.currentTime = 0;
const result = nativePlay && nativePlay.call(media); const result = nativePlay && nativePlay.call(media);
if (result && result.catch) result.catch(() => {}); if (result && result.then) {
result.then(() => { media.__rdReaderReplayWhenVisible = false; }).catch(() => {});
} else {
media.__rdReaderReplayWhenVisible = false;
}
} catch (_) {} } catch (_) {}
} }
} else { } else {
@ -191,6 +328,7 @@ extension RDEPUBWebView {
} }
} }
}); });
trackedAudioContexts.forEach(updateAudioContext);
}; };
if (mediaPrototype && nativePlay) { if (mediaPrototype && nativePlay) {
@ -218,34 +356,212 @@ extension RDEPUBWebView {
window.Audio = ReaderAudio; window.Audio = ReaderAudio;
} }
const installAudioContext = name => {
const NativeAudioContext = window[name];
if (typeof NativeAudioContext !== "function") return;
const ReaderAudioContext = function() {
const context = Reflect.construct(
NativeAudioContext,
Array.prototype.slice.call(arguments),
NativeAudioContext
);
return registerAudioContext(context);
};
ReaderAudioContext.prototype = NativeAudioContext.prototype;
try { Object.setPrototypeOf(ReaderAudioContext, NativeAudioContext); } catch (_) {}
try { window[name] = ReaderAudioContext; } catch (_) {}
};
installAudioContext("AudioContext");
installAudioContext("webkitAudioContext");
const sendVisibility = target => {
try {
target.postMessage({ type: command, active: isActive }, "*");
} catch (_) {}
};
const broadcastVisibility = () => {
for (let index = 0; index < window.frames.length; index += 1) {
sendVisibility(window.frames[index]);
}
};
window.addEventListener("message", event => { window.addEventListener("message", event => {
const message = event.data; const message = event.data;
if (!message || message.type !== command) return; if (!message) return;
setActive(message.active); if (message.type === frameReadyCommand) {
for (let index = 0; index < window.frames.length; index += 1) { if (event.source) sendVisibility(event.source);
try { window.frames[index].postMessage(message, "*"); } catch (_) {} return;
} }
if (message.type !== command) return;
setActive(message.active);
broadcastVisibility();
}); });
document.addEventListener("DOMContentLoaded", () => setActive(isActive)); document.addEventListener("DOMContentLoaded", () => {
setActive(isActive);
broadcastVisibility();
});
if (window.MutationObserver) { if (window.MutationObserver) {
new MutationObserver(() => setActive(isActive)).observe(document, { new MutationObserver(() => setActive(isActive)).observe(document, {
childList: true, childList: true,
subtree: true subtree: true
}); });
} }
if (window.parent !== window) {
try { window.parent.postMessage({ type: frameReadyCommand }, "*"); } catch (_) {}
}
})(); })();
"""# """#
func setReaderPageVisible(_ isVisible: Bool) { func setReaderPageVisible(_ isVisible: Bool) {
let wasVisible = isReaderPageVisible
isReaderPageVisible = isVisible isReaderPageVisible = isVisible
if !isVisible {
if wasVisible,
hasReaderPageBeenVisible,
currentRenderRequest?.isFixedLayout == true {
needsReaderPagePlaybackRestart = true
}
applyReaderPageMediaVisibility() applyReaderPageMediaVisibility()
return
}
if !wasVisible,
needsReaderPagePlaybackRestart,
restartReaderPagePlayback() {
return
}
guard !isWaitingForReaderPageMediaNavigation else { return }
applyReaderPageMediaVisibility()
}
func readerPageMediaNavigationDidFinish(_ navigation: WKNavigation?) -> Bool {
if isWaitingForReaderPageMediaNavigation,
let pendingReaderPageMediaNavigation,
let navigation,
pendingReaderPageMediaNavigation !== navigation {
return false
}
pendingReaderPageMediaNavigation = nil
isReaderPageMediaActivationBlocked = false
isWaitingForReaderPageMediaNavigation = false
applyReaderPageMediaVisibility()
return true
}
func readerPageMediaNavigationDidFail(_ navigation: WKNavigation?) {
if let pendingReaderPageMediaNavigation,
let navigation,
pendingReaderPageMediaNavigation !== navigation {
return
}
pendingReaderPageMediaNavigation = nil
isReaderPageMediaActivationBlocked = true
isWaitingForReaderPageMediaNavigation = false
// A failed fixed-layout reload may leave the outgoing Hype document
// alive. Keep it suspended instead of reviving narration from the page
// the user already left; the next successful load will activate again.
setNativeReaderPageMediaPlaybackSuspended(true)
webView?.evaluateJavaScript(
"window.postMessage({type: 'rd-reader-page-visibility', active: false}, '*');",
completionHandler: nil
)
} }
func applyReaderPageMediaVisibility() { func applyReaderPageMediaVisibility() {
guard let webView else { return } guard let webView else { return }
let active = isReaderPageVisible ? "true" : "false" let isVisible = isReaderPageVisible && !isReaderPageMediaActivationBlocked
let script = "window.postMessage({type: 'rd-reader-page-visibility', active: \(active)}, '*');" if !isVisible {
webView.evaluateJavaScript(script, completionHandler: nil) setNativeReaderPageMediaPlaybackSuspended(true)
webView.evaluateJavaScript(
"window.postMessage({type: 'rd-reader-page-visibility', active: false}, '*');",
completionHandler: nil
)
return
}
hasReaderPageBeenVisible = true
setNativeReaderPageMediaPlaybackSuspended(false) { [weak self, weak webView] in
guard let self,
let webView,
self.webView === webView,
self.isReaderPageVisible,
!self.isReaderPageMediaActivationBlocked,
!self.isWaitingForReaderPageMediaNavigation else { return }
webView.evaluateJavaScript(
"window.postMessage({type: 'rd-reader-page-visibility', active: true}, '*');",
completionHandler: nil
)
}
}
private func restartReaderPagePlayback() -> Bool {
guard let publication,
let currentRenderRequest,
currentRenderRequest.isFixedLayout else { return false }
load(publication: publication, request: currentRenderRequest)
return true
}
func setNativeReaderPageMediaPlaybackSuspended(
_ suspended: Bool,
completion: @escaping () -> Void = {}
) {
RDEPUBReaderMediaPlaybackCoordinator.shared.setPlaybackSuspended(
suspended,
for: self,
completion: completion
)
}
func applyNativeMediaPlaybackSuspended(_ suspended: Bool, completion: @escaping () -> Void) {
nativeMediaPlaybackRequests.append((suspended, completion))
processNextNativeMediaPlaybackRequest()
}
private func processNextNativeMediaPlaybackRequest() {
guard !isNativeMediaPlaybackOperationInFlight else { return }
guard let webView else {
finishAllNativeMediaPlaybackRequests()
return
}
guard let request = nativeMediaPlaybackRequests.first else { return }
guard appliedNativeMediaPlaybackSuspended != request.suspended else {
nativeMediaPlaybackRequests.removeFirst()
request.completion()
processNextNativeMediaPlaybackRequest()
return
}
let targetSuspended = request.suspended
isNativeMediaPlaybackOperationInFlight = true
nativeMediaPlaybackOperationGeneration &+= 1
let operationGeneration = nativeMediaPlaybackOperationGeneration
webView.setAllMediaPlaybackSuspended(targetSuspended) { [weak self, weak webView] in
guard let self else { return }
guard operationGeneration == self.nativeMediaPlaybackOperationGeneration,
let webView,
self.webView === webView else { return }
self.appliedNativeMediaPlaybackSuspended = targetSuspended
self.isNativeMediaPlaybackOperationInFlight = false
guard let completedRequest = self.nativeMediaPlaybackRequests.first else { return }
self.nativeMediaPlaybackRequests.removeFirst()
completedRequest.completion()
self.processNextNativeMediaPlaybackRequest()
}
}
private func finishAllNativeMediaPlaybackRequests() {
let completions = nativeMediaPlaybackRequests.map(\.completion)
nativeMediaPlaybackRequests.removeAll()
nativeMediaPlaybackOperationGeneration &+= 1
appliedNativeMediaPlaybackSuspended = false
isNativeMediaPlaybackOperationInFlight = false
completions.forEach { $0() }
} }
} }

View File

@ -48,7 +48,7 @@ extension RDEPUBWebView {
let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication) let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication)
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)") RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
webView.loadHTMLString( pendingReaderPageMediaNavigation = webView.loadHTMLString(
html, html,
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/") baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
) )

View File

@ -31,20 +31,33 @@ extension RDEPUBWebView: WKNavigationDelegate {
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url) RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
applyReaderPageMediaVisibility() guard readerPageMediaNavigationDidFinish(navigation) else { return }
applyPresentation() applyPresentation()
} }
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url) RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
// WebKit may not complete an in-flight media suspension after its
// content process exits. Tearing down flushes the FIFO callbacks and
// lets the coordinator activate another page without deadlocking.
// Rebuild the page afterward so a cached/current page does not remain
// permanently blank after WebKit recovers its content process.
let publication = publication
let renderRequest = currentRenderRequest
teardownWebView()
if let publication, let renderRequest {
load(publication: publication, request: renderRequest)
}
} }
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error) RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
readerPageMediaNavigationDidFail(navigation)
} }
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error) RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
readerPageMediaNavigationDidFail(navigation)
} }
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {

View File

@ -74,9 +74,9 @@ extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate {
menuFor configuration: UIEditMenuConfiguration menuFor configuration: UIEditMenuConfiguration
) -> UIMenu { ) -> UIMenu {
UIMenu(children: [ UIMenu(children: [
UICommand(title: "拷贝", action: #selector(rd_copy(_:))), UICommand(title: "复制", action: #selector(rd_copy(_:))),
UICommand(title: "高亮", action: #selector(rd_highlight(_:))), UICommand(title: "划线", action: #selector(rd_highlight(_:))),
UICommand(title: "", action: #selector(rd_annotate(_:))) UICommand(title: "", action: #selector(rd_annotate(_:)))
]) ])
} }
@ -138,6 +138,24 @@ public final class RDEPUBWebView: UIView {
var isReaderPageVisible = true var isReaderPageVisible = true
var hasReaderPageBeenVisible = false
var needsReaderPagePlaybackRestart = false
var appliedNativeMediaPlaybackSuspended = false
var isNativeMediaPlaybackOperationInFlight = false
var nativeMediaPlaybackOperationGeneration: UInt = 0
var nativeMediaPlaybackRequests: [(suspended: Bool, completion: () -> Void)] = []
var isWaitingForReaderPageMediaNavigation = false
var pendingReaderPageMediaNavigation: WKNavigation?
var isReaderPageMediaActivationBlocked = false
var pendingProgressionRequest = false var pendingProgressionRequest = false
var fixedLayoutReadyWorkItem: DispatchWorkItem? var fixedLayoutReadyWorkItem: DispatchWorkItem?
@ -189,6 +207,11 @@ public final class RDEPUBWebView: UIView {
fixedSpread = nil fixedSpread = nil
isFixedLayout = false isFixedLayout = false
isReaderPageVisible = true isReaderPageVisible = true
hasReaderPageBeenVisible = false
needsReaderPagePlaybackRestart = false
isWaitingForReaderPageMediaNavigation = false
pendingReaderPageMediaNavigation = nil
isReaderPageMediaActivationBlocked = false
pendingProgressionRequest = false pendingProgressionRequest = false
didRenderCurrentRequest = false didRenderCurrentRequest = false
teardownWebView() teardownWebView()
@ -207,6 +230,15 @@ public final class RDEPUBWebView: UIView {
let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? "" let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? ""
let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request) let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request)
if request.isFixedLayout {
// A fixed-layout load replaces the whole Hype document. Keep WebKit
// suspended until navigation finishes so the outgoing page cannot
// overlap the new page's zero-second narration.
hasReaderPageBeenVisible = false
needsReaderPagePlaybackRestart = false
isWaitingForReaderPageMediaNavigation = true
setNativeReaderPageMediaPlaybackSuspended(true)
}
RDEPUBWebViewDebug.log( RDEPUBWebViewDebug.log(
debugScope, debugScope,
message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))" message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))"

View File

@ -0,0 +1,135 @@
import UIKit
final class RDEPUBAnnotationEditorViewController: UIViewController, UITextViewDelegate {
private let quote: String
private let initialNote: String?
private let onSave: (String?) -> Void
private let textView = UITextView()
private let countLabel = UILabel()
init(quote: String, initialNote: String?, onSave: @escaping (String?) -> Void) {
self.quote = quote
self.initialNote = initialNote
self.onSave = onSave
super.init(nibName: nil, bundle: nil)
title = "写注释"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
configureNavigation()
configureContent()
updateCount()
DispatchQueue.main.async { [weak self] in self?.textView.becomeFirstResponder() }
}
private func configureNavigation() {
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "取消", style: .plain, target: self, action: #selector(cancelAction)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "保存", style: .done, target: self, action: #selector(saveAction)
)
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
navigationController?.navigationBar.titleTextAttributes = [
.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1),
.font: UIFont.systemFont(ofSize: 17, weight: .semibold)
]
}
private func configureContent() {
let quoteCard = UIView()
let rail = UIView()
let quoteLabel = UILabel()
let inputCard = UIView()
[quoteCard, rail, quoteLabel, inputCard, textView, countLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
view.addSubview(quoteCard)
quoteCard.addSubview(rail)
quoteCard.addSubview(quoteLabel)
view.addSubview(inputCard)
inputCard.addSubview(textView)
inputCard.addSubview(countLabel)
quoteCard.backgroundColor = UIColor.white.withAlphaComponent(0.78)
quoteCard.layer.cornerRadius = 16
rail.backgroundColor = UIColor(red: 0.94, green: 0.63, blue: 0.18, alpha: 1)
rail.layer.cornerRadius = 2
quoteLabel.text = quote
quoteLabel.numberOfLines = 5
quoteLabel.font = UIFont.systemFont(ofSize: 16)
quoteLabel.textColor = UIColor(red: 0.19, green: 0.20, blue: 0.23, alpha: 1)
inputCard.backgroundColor = .white
inputCard.layer.cornerRadius = 18
inputCard.layer.shadowColor = UIColor.black.cgColor
inputCard.layer.shadowOpacity = 0.06
inputCard.layer.shadowRadius = 18
inputCard.layer.shadowOffset = CGSize(width: 0, height: 6)
textView.text = initialNote
textView.delegate = self
textView.font = UIFont.systemFont(ofSize: 17)
textView.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
textView.backgroundColor = .clear
textView.accessibilityIdentifier = "epub.reader.annotation.editor"
countLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular)
countLabel.textColor = .secondaryLabel
countLabel.textAlignment = .right
NSLayoutConstraint.activate([
quoteCard.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
quoteCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
quoteCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
rail.leadingAnchor.constraint(equalTo: quoteCard.leadingAnchor, constant: 16),
rail.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
rail.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
rail.widthAnchor.constraint(equalToConstant: 4),
quoteLabel.leadingAnchor.constraint(equalTo: rail.trailingAnchor, constant: 14),
quoteLabel.trailingAnchor.constraint(equalTo: quoteCard.trailingAnchor, constant: -18),
quoteLabel.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
quoteLabel.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
inputCard.topAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: 18),
inputCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
inputCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
inputCard.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -16),
inputCard.heightAnchor.constraint(greaterThanOrEqualToConstant: 210),
textView.topAnchor.constraint(equalTo: inputCard.topAnchor, constant: 14),
textView.leadingAnchor.constraint(equalTo: inputCard.leadingAnchor, constant: 14),
textView.trailingAnchor.constraint(equalTo: inputCard.trailingAnchor, constant: -14),
textView.bottomAnchor.constraint(equalTo: countLabel.topAnchor, constant: -8),
countLabel.leadingAnchor.constraint(equalTo: inputCard.leadingAnchor, constant: 16),
countLabel.trailingAnchor.constraint(equalTo: inputCard.trailingAnchor, constant: -16),
countLabel.bottomAnchor.constraint(equalTo: inputCard.bottomAnchor, constant: -12)
])
}
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 1000 {
textView.text = String(textView.text.prefix(1000))
}
updateCount()
}
private func updateCount() {
countLabel.text = "\(textView.text.count)/1000"
}
@objc private func cancelAction() {
dismiss(animated: true)
}
@objc private func saveAction() {
let note = textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
onSave(note.isEmpty ? nil : note)
dismiss(animated: true)
}
}

View File

@ -52,7 +52,7 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView, RDEPUBReaderBottom
configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录") configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录")
configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签") configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "") configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "")
configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注") configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置") configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
chapterButton.accessibilityIdentifier = "epub.reader.toc" chapterButton.accessibilityIdentifier = "epub.reader.toc"

View File

@ -130,10 +130,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
func textContentView( func textContentView(
_ contentView: RDEPUBTextContentView, _ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight, didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
sourceRect: CGRect highlight: RDEPUBHighlight
) { ) {
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect) runtime.handleHighlightMenuAction(action, highlight: highlight)
} }
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool { private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {

View File

@ -5,14 +5,16 @@ extension RDEPUBReaderController {
func applyReaderViewConfiguration() { func applyReaderViewConfiguration() {
let resolvedDirection = resolvedPageDirection() let resolvedDirection = resolvedPageDirection()
let allowsLandscapeDualPage = configuration.landscapeDualPageEnabled
&& publication?.requiresSinglePagePresentation != true
let presentationDidChange = readerView.currentDisplayType != configuration.displayType let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled || readerView.landscapeDualPageEnabled != allowsLandscapeDualPage
|| readerView.pageDirection != resolvedDirection || readerView.pageDirection != resolvedDirection
let preservedLocation = presentationDidChange let preservedLocation = presentationDidChange
? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation()) ? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
: nil : nil
view.backgroundColor = configuration.theme.contentBackgroundColor view.backgroundColor = configuration.theme.contentBackgroundColor
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled readerView.landscapeDualPageEnabled = allowsLandscapeDualPage
readerView.pageDirection = resolvedDirection readerView.pageDirection = resolvedDirection
updateReaderChrome() updateReaderChrome()
if presentationDidChange { if presentationDidChange {

View File

@ -374,14 +374,11 @@ public final class RDEPUBReaderController: UIViewController {
readerView.addSubview(searchBarView) readerView.addSubview(searchBarView)
readerView.searchBarView = searchBarView readerView.searchBarView = searchBarView
let bottomAnchor = bottomToolView.superview == nil
? readerView.bottomAnchor
: bottomToolView.topAnchor
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor), searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor), searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor), searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor) searchBarView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor)
]) ])
searchBarView.alpha = 0 searchBarView.alpha = 0

View File

@ -14,14 +14,14 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private let sectionTitleProvider: (RDEPUBHighlight) -> String? private let sectionTitleProvider: (RDEPUBHighlight) -> String?
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"]) private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"])
private var filteredHighlights: [RDEPUBHighlight] { private var filteredHighlights: [RDEPUBHighlight] {
switch filterControl.selectedSegmentIndex { switch filterControl.selectedSegmentIndex {
case 1: case 1:
return highlights.filter(\.hasNote) return highlights.filter(\.hasNote)
case 2: case 2:
return highlights.filter { $0.style == .highlight } return highlights.filter { !$0.hasNote }
default: default:
return highlights return highlights
} }
@ -48,7 +48,11 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
view.accessibilityIdentifier = "epub.reader.highlights.panel" view.accessibilityIdentifier = "epub.reader.highlights.panel"
tableView.accessibilityIdentifier = "epub.reader.highlights.table" tableView.accessibilityIdentifier = "epub.reader.highlights.table"
tableView.tableFooterView = UIView(frame: .zero) tableView.tableFooterView = UIView(frame: .zero)
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) tableView.separatorStyle = .none
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
navigationItem.rightBarButtonItem = editButtonItem
editButtonItem.title = "编辑"
tableView.allowsMultipleSelectionDuringEditing = false
configureFilterControl() configureFilterControl()
applyTheme() applyTheme()
updateEmptyState() updateEmptyState()
@ -63,23 +67,42 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier) let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
let highlight = filteredHighlights[indexPath.row] let highlight = filteredHighlights[indexPath.row]
cell.backgroundColor = theme.contentBackgroundColor cell.backgroundColor = .clear
cell.textLabel?.textColor = theme.contentTextColor cell.contentView.backgroundColor = UIColor.white.withAlphaComponent(0.76)
cell.contentView.layer.cornerRadius = 14
cell.contentView.layer.masksToBounds = true
cell.textLabel?.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
cell.textLabel?.numberOfLines = 2 cell.textLabel?.numberOfLines = 2
cell.textLabel?.text = titleText(for: highlight) cell.textLabel?.text = titleText(for: highlight)
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7) cell.detailTextLabel?.textColor = UIColor(red: 0.34, green: 0.36, blue: 0.40, alpha: 1)
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12) cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
cell.detailTextLabel?.numberOfLines = 3 cell.detailTextLabel?.numberOfLines = 3
cell.detailTextLabel?.text = detailText(for: highlight) cell.detailTextLabel?.text = detailText(for: highlight)
cell.accessoryType = .disclosureIndicator cell.accessoryType = .none
return cell return cell
} }
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 112 }
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cell.contentView.frame = cell.bounds.inset(by: UIEdgeInsets(top: 6, left: 18, bottom: 6, right: 18))
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard !tableView.isEditing else { return }
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath) onSelectHighlight?(filteredHighlights[indexPath.row])
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
true
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else { return }
deleteHighlight(filteredHighlights[indexPath.row])
} }
private func configureFilterControl() { private func configureFilterControl() {
@ -90,10 +113,10 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
} }
private func applyTheme() { private func applyTheme() {
tableView.backgroundColor = theme.contentBackgroundColor tableView.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
navigationController?.navigationBar.tintColor = theme.toolControlTextColor navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor navigationController?.navigationBar.barTintColor = tableView.backgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar" navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
} }
@ -120,7 +143,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private func emptyStateText() -> String { private func emptyStateText() -> String {
switch filterControl.selectedSegmentIndex { switch filterControl.selectedSegmentIndex {
case 1: case 1:
return "暂无" return "暂无"
case 2: case 2:
return "暂无划线" return "暂无划线"
default: default:
@ -142,7 +165,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private func titleText(for highlight: RDEPUBHighlight) -> String { private func titleText(for highlight: RDEPUBHighlight) -> String {
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines) let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
if highlight.hasNote { if highlight.hasNote {
return "批注: \(text)" return "注释:\(text)"
} }
return text return text
} }
@ -150,58 +173,12 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private func styleDescription(for highlight: RDEPUBHighlight) -> String { private func styleDescription(for highlight: RDEPUBHighlight) -> String {
switch highlight.style { switch highlight.style {
case .highlight: case .highlight:
return highlight.hasNote ? "高亮批注" : "高亮" return highlight.hasNote ? "划线注释" : "划线"
case .underline: case .underline:
return highlight.hasNote ? "划线" : "划线" return highlight.hasNote ? "划线" : "划线"
} }
} }
private func presentActions(for highlight: RDEPUBHighlight, sourceIndexPath: IndexPath) {
let alert = UIAlertController(title: "标注管理", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
self?.onSelectHighlight?(highlight)
})
alert.addAction(UIAlertAction(title: "编辑批注", style: .default) { [weak self] _ in
self?.presentNoteEditor(for: highlight)
})
alert.addAction(UIAlertAction(title: "删除标注", style: .destructive) { [weak self] _ in
self?.deleteHighlight(highlight)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController,
let cell = tableView.cellForRow(at: sourceIndexPath) {
popover.sourceView = cell
popover.sourceRect = cell.bounds
}
present(alert, animated: true)
}
private func presentNoteEditor(for highlight: RDEPUBHighlight) {
let alert = UIAlertController(title: "编辑批注", message: nil, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "输入批注内容"
textField.text = highlight.note
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
guard let self,
let note = alert?.textFields?.first?.text,
let index = self.highlights.firstIndex(where: { $0.id == highlight.id }) else {
return
}
var updated = highlight
updated.note = self.normalizedNote(note)
self.highlights[index] = updated
self.tableView.reloadData()
self.onUpdateHighlight?(updated)
self.updateEmptyState()
})
present(alert, animated: true)
}
private func deleteHighlight(_ highlight: RDEPUBHighlight) { private func deleteHighlight(_ highlight: RDEPUBHighlight) {
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return } guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
highlights.remove(at: index) highlights.remove(at: index)
@ -248,7 +225,10 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
view.accessibilityIdentifier = "epub.reader.bookmarks.panel" view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table" tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
tableView.tableFooterView = UIView(frame: .zero) tableView.tableFooterView = UIView(frame: .zero)
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) tableView.separatorStyle = .none
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
navigationItem.rightBarButtonItem = editButtonItem
editButtonItem.title = "编辑"
applyTheme() applyTheme()
updateEmptyState() updateEmptyState()
} }
@ -262,30 +242,40 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier) let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
let bookmark = bookmarks[indexPath.row] let bookmark = bookmarks[indexPath.row]
cell.backgroundColor = theme.contentBackgroundColor cell.backgroundColor = .clear
cell.textLabel?.textColor = theme.contentTextColor cell.contentView.backgroundColor = UIColor.white.withAlphaComponent(0.76)
cell.contentView.layer.cornerRadius = 14
cell.contentView.layer.masksToBounds = true
cell.textLabel?.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
cell.textLabel?.numberOfLines = 2 cell.textLabel?.numberOfLines = 2
cell.textLabel?.text = titleText(for: bookmark) cell.textLabel?.text = titleText(for: bookmark)
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7) cell.detailTextLabel?.textColor = UIColor(red: 0.34, green: 0.36, blue: 0.40, alpha: 1)
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12) cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
cell.detailTextLabel?.numberOfLines = 3 cell.detailTextLabel?.numberOfLines = 3
cell.detailTextLabel?.text = detailText(for: bookmark) cell.detailTextLabel?.text = detailText(for: bookmark)
cell.accessoryType = .disclosureIndicator cell.accessoryType = .none
return cell return cell
} }
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 104 }
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else { return }
deleteBookmark(bookmarks[indexPath.row])
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath) onSelectBookmark?(bookmarks[indexPath.row])
} }
private func applyTheme() { private func applyTheme() {
tableView.backgroundColor = theme.contentBackgroundColor tableView.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
navigationController?.navigationBar.tintColor = theme.toolControlTextColor navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor navigationController?.navigationBar.barTintColor = tableView.backgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar" navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
} }
@ -322,25 +312,6 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
return parts.joined(separator: "\n") return parts.joined(separator: "\n")
} }
private func presentActions(for bookmark: RDEPUBBookmark, sourceIndexPath: IndexPath) {
let alert = UIAlertController(title: "书签管理", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
self?.onSelectBookmark?(bookmark)
})
alert.addAction(UIAlertAction(title: "删除书签", style: .destructive) { [weak self] _ in
self?.deleteBookmark(bookmark)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController,
let cell = tableView.cellForRow(at: sourceIndexPath) {
popover.sourceView = cell
popover.sourceRect = cell.bounds
}
present(alert, animated: true)
}
private func deleteBookmark(_ bookmark: RDEPUBBookmark) { private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return } guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
bookmarks.remove(at: index) bookmarks.remove(at: index)

View File

@ -119,21 +119,13 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
super.apply(theme: theme) super.apply(theme: theme)
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
let overlayColor = isDarkBackground let overlayColor = UIColor(white: 0.05, alpha: 0.32)
? UIColor(white: 0.12, alpha: 0.92) let panelColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
: UIColor(white: 0.08, alpha: 0.82) let rowColor = UIColor.white.withAlphaComponent(0.88)
let panelColor = isDarkBackground let cardColor = UIColor.white.withAlphaComponent(0.78)
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1) let activeCardColor = UIColor(red: 0.90, green: 0.94, blue: 1.0, alpha: 1)
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1) let textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
let rowColor = isDarkBackground let secondaryTextColor = UIColor(red: 0.38, green: 0.40, blue: 0.44, alpha: 1)
? UIColor(white: 0.18, alpha: 1)
: UIColor(white: 0.14, alpha: 0.96)
let cardColor = isDarkBackground
? UIColor(white: 0.12, alpha: 1)
: UIColor(white: 0.10, alpha: 0.98)
let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
let textColor = UIColor(white: 0.96, alpha: 1)
let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
backgroundColor = .clear backgroundColor = .clear
backgroundButton.backgroundColor = overlayColor backgroundButton.backgroundColor = overlayColor
@ -141,7 +133,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7) grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
searchRowView.backgroundColor = rowColor searchRowView.backgroundColor = rowColor
searchFieldContainer.backgroundColor = .clear searchFieldContainer.backgroundColor = .clear
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12) searchFieldDivider.backgroundColor = UIColor.black.withAlphaComponent(0.10)
searchIcon.tintColor = secondaryTextColor searchIcon.tintColor = secondaryTextColor
cancelButton.tintColor = textColor cancelButton.tintColor = textColor
cancelButton.setTitleColor(textColor, for: .normal) cancelButton.setTitleColor(textColor, for: .normal)
@ -165,8 +157,8 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
RDEPUBReaderSearchResultCell.primaryTextColor = textColor RDEPUBReaderSearchResultCell.primaryTextColor = textColor
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue RDEPUBReaderSearchResultCell.highlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1) RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
tableView.reloadData() tableView.reloadData()
} }
@ -274,10 +266,10 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
searchIcon.contentMode = .scaleAspectFit searchIcon.contentMode = .scaleAspectFit
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular) searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
panelView.layer.cornerRadius = 28 panelView.layer.cornerRadius = 0
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.clipsToBounds = true panelView.clipsToBounds = true
grabberView.isHidden = true
grabberView.layer.cornerRadius = 3 grabberView.layer.cornerRadius = 3
searchRowView.layer.cornerRadius = 22 searchRowView.layer.cornerRadius = 22
searchFieldContainer.layer.cornerRadius = 22 searchFieldContainer.layer.cornerRadius = 22
@ -317,7 +309,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
panelView.leadingAnchor.constraint(equalTo: leadingAnchor), panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
panelView.trailingAnchor.constraint(equalTo: trailingAnchor), panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8), panelView.topAnchor.constraint(equalTo: topAnchor),
panelView.bottomAnchor.constraint(equalTo: bottomAnchor), panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10), grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
@ -327,7 +319,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20), searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20), searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18), searchRowView.topAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.topAnchor, constant: 12),
searchRowView.heightAnchor.constraint(equalToConstant: 52), searchRowView.heightAnchor.constraint(equalToConstant: 52),
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12), searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
@ -480,7 +472,7 @@ extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate
let label = UILabel() let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 19, weight: .bold) label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
label.textColor = UIColor(white: 0.96, alpha: 1) label.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
label.text = searchSections[section].title label.text = searchSections[section].title
label.numberOfLines = 2 label.numberOfLines = 2
container.addSubview(label) container.addSubview(label)

View File

@ -339,6 +339,7 @@ public final class RDEpubURLReaderController: UIViewController {
let state = [ let state = [
"reader=opened", "reader=opened",
"page=\(page)", "page=\(page)",
"pagesPerScreen=\(readerController?.readerView.pagesPerScreen ?? 1)",
"display=\(display)", "display=\(display)",
"toolbar=\(toolbar)", "toolbar=\(toolbar)",
"highlights=\(highlights)", "highlights=\(highlights)",

View File

@ -53,7 +53,7 @@ final class RDEPUBReaderAnnotationCoordinator {
color: String = "#F8E16C", color: String = "#F8E16C",
note: String? = nil note: String? = nil
) -> RDEPUBHighlight? { ) -> RDEPUBHighlight? {
addAnnotation(from: selection, style: .highlight, color: color, note: note) addAnnotation(from: selection, style: .underline, color: color, note: note)
} }
@discardableResult @discardableResult
@ -61,7 +61,8 @@ final class RDEPUBReaderAnnotationCoordinator {
from selection: RDEPUBSelection? = nil, from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle, style: RDEPUBHighlightStyle,
color: String = "#F8E16C", color: String = "#F8E16C",
note: String? = nil note: String? = nil,
isAnnotationOnly: Bool = false
) -> RDEPUBHighlight? { ) -> RDEPUBHighlight? {
guard let controller else { return nil } guard let controller else { return nil }
let sourceSelection = selection ?? controller.currentSelection let sourceSelection = selection ?? controller.currentSelection
@ -71,22 +72,35 @@ final class RDEPUBReaderAnnotationCoordinator {
return nil return nil
} }
let newHighlight = RDEPUBHighlight( var newHighlight = RDEPUBHighlight(
bookIdentifier: controller.currentBookIdentifier, bookIdentifier: controller.currentBookIdentifier,
location: scopedSelection.location, location: scopedSelection.location,
text: scopedSelection.text, text: scopedSelection.text,
rangeInfo: scopedSelection.rangeInfo, rangeInfo: scopedSelection.rangeInfo,
style: style, style: .underline,
color: color, color: color,
note: note note: note,
isAnnotationOnly: isAnnotationOnly
) )
let mergeCandidates = controller.activeHighlights.filter {
shouldMerge($0, with: newHighlight)
}
if !mergeCandidates.isEmpty {
newHighlight = mergedHighlight(newHighlight, with: mergeCandidates)
controller.activeHighlights.removeAll { candidate in
mergeCandidates.contains { $0.id == candidate.id }
}
}
let isDuplicate = controller.activeHighlights.contains { highlight in let isDuplicate = controller.activeHighlights.contains { highlight in
highlight.location.href == newHighlight.location.href && highlight.location.href == newHighlight.location.href &&
highlight.location.fragment == newHighlight.location.fragment && highlight.location.fragment == newHighlight.location.fragment &&
highlight.text == newHighlight.text && highlight.text == newHighlight.text &&
highlight.rangeInfo == newHighlight.rangeInfo && highlight.rangeInfo == newHighlight.rangeInfo &&
highlight.style == newHighlight.style highlight.style == newHighlight.style &&
highlight.isAnnotationOnly == newHighlight.isAnnotationOnly
} }
guard !isDuplicate else { guard !isDuplicate else {
return nil return nil
@ -219,25 +233,29 @@ final class RDEPUBReaderAnnotationCoordinator {
presentAnnotationActionSheet(for: currentSelection) presentAnnotationActionSheet(for: currentSelection)
} }
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) { func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
guard let controller else { return } switch action {
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet) case .copy:
alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in UIPasteboard.general.string = highlight.text
_ = self?.removeHighlight(id: highlight.id) case .createUnderline:
}) guard highlight.isAnnotationOnly,
if highlight.hasNote { let controller,
alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in let index = controller.activeHighlights.firstIndex(where: { $0.id == highlight.id }) else {
_ = self?.updateHighlightNote(id: highlight.id, note: nil) return
}) }
controller.activeHighlights[index].isAnnotationOnly = false
persistHighlightsAndRefreshContent()
case .deleteUnderline:
_ = removeHighlight(id: highlight.id)
case .annotate:
presentAnnotationNoteEditor(for: highlight)
case .deleteAnnotation:
if highlight.isAnnotationOnly {
_ = removeHighlight(id: highlight.id)
} else {
_ = updateHighlightNote(id: highlight.id, note: nil)
} }
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = sourceView
popover.sourceRect = sourceRect
} }
controller.present(alert, animated: true)
} }
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
@ -247,7 +265,7 @@ final class RDEPUBReaderAnnotationCoordinator {
UIPasteboard.general.string = selection.text UIPasteboard.general.string = selection.text
updateCurrentSelection(nil) updateCurrentSelection(nil)
case .highlight: case .highlight:
createAnnotation(from: selection, style: .highlight) createAnnotation(from: selection, style: .underline)
case .annotate: case .annotate:
presentAnnotationNoteEditor(for: selection) presentAnnotationNoteEditor(for: selection)
} }
@ -365,13 +383,65 @@ final class RDEPUBReaderAnnotationCoordinator {
location: normalizedLocation, location: normalizedLocation,
text: highlight.text, text: highlight.text,
rangeInfo: highlight.rangeInfo, rangeInfo: highlight.rangeInfo,
style: highlight.style, style: .underline,
color: highlight.color, color: highlight.color,
note: highlight.note, note: highlight.note,
isAnnotationOnly: highlight.isAnnotationOnly,
createdAt: highlight.createdAt createdAt: highlight.createdAt
) )
} }
private func shouldMerge(_ existing: RDEPUBHighlight, with incoming: RDEPUBHighlight) -> Bool {
guard existing.location.href == incoming.location.href,
let existingRange = RDEPUBTextOffsetRangeInfo.decode(from: existing.rangeInfo)?.nsRange,
let incomingRange = RDEPUBTextOffsetRangeInfo.decode(from: incoming.rangeInfo)?.nsRange else {
return false
}
// Only merge identical selections. Contained or partially-overlapping
// ranges remain independent so a reader can still manage each mark.
return existingRange.location == incomingRange.location
&& existingRange.length == incomingRange.length
}
private func mergedHighlight(
_ incoming: RDEPUBHighlight,
with existingHighlights: [RDEPUBHighlight]
) -> RDEPUBHighlight {
var result = incoming
let allHighlights = existingHighlights + [incoming]
let ranges = allHighlights.compactMap {
RDEPUBTextOffsetRangeInfo.decode(from: $0.rangeInfo)
}
if let first = ranges.first {
let start = ranges.map(\.start).min() ?? first.start
let end = ranges.map(\.end).max() ?? first.end
result.rangeInfo = RDEPUBTextOffsetRangeInfo(href: first.href, start: start, end: end).jsonString()
}
// The latest selection is exact whenever it encloses the merged range;
// otherwise keep every distinct excerpt rather than silently losing
// text that may later be copied from the marking list.
if let incomingRange = RDEPUBTextOffsetRangeInfo.decode(from: incoming.rangeInfo),
let mergedRange = RDEPUBTextOffsetRangeInfo.decode(from: result.rangeInfo),
incomingRange.start <= mergedRange.start,
incomingRange.end >= mergedRange.end {
result.text = incoming.text
} else {
result.text = allHighlights.map(\.text).reduce(into: [String]()) { texts, text in
if !texts.contains(text) { texts.append(text) }
}.joined(separator: " ")
}
let notes = allHighlights.compactMap(\.note).map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty }
let uniqueNotes = notes.reduce(into: [String]()) { values, note in
if !values.contains(note) { values.append(note) }
}
result.note = uniqueNotes.isEmpty ? nil : uniqueNotes.joined(separator: "\n\n")
return result
}
@discardableResult @discardableResult
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool { private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
guard let controller else { return false } guard let controller else { return false }
@ -410,13 +480,10 @@ final class RDEPUBReaderAnnotationCoordinator {
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) { private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
guard let controller else { return } guard let controller else { return }
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet) let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in
self?.createAnnotation(from: selection, style: .highlight)
})
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
self?.createAnnotation(from: selection, style: .underline) self?.createAnnotation(from: selection, style: .underline)
}) })
alert.addAction(UIAlertAction(title: "", style: .default) { [weak self] _ in alert.addAction(UIAlertAction(title: "", style: .default) { [weak self] _ in
self?.presentAnnotationNoteEditor(for: selection) self?.presentAnnotationNoteEditor(for: selection)
}) })
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@ -429,25 +496,48 @@ final class RDEPUBReaderAnnotationCoordinator {
controller.present(alert, animated: true) controller.present(alert, animated: true)
} }
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) { private func createAnnotation(
_ = addAnnotation(from: selection, style: style, note: note) from selection: RDEPUBSelection,
style: RDEPUBHighlightStyle,
note: String? = nil,
isAnnotationOnly: Bool = false
) {
_ = addAnnotation(
from: selection,
style: style,
note: note,
isAnnotationOnly: isAnnotationOnly
)
} }
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) { private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
guard let controller else { return } presentNoteEditor(quote: selection.text, initialNote: nil) { [weak self] note in
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert) guard note != nil else { return }
alert.addTextField { textField in
textField.placeholder = "输入批注内容"
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
self?.createAnnotation( self?.createAnnotation(
from: selection, from: selection,
style: .highlight, style: .underline,
note: alert?.textFields?.first?.text note: note,
isAnnotationOnly: true
) )
}) }
controller.present(alert, animated: true) }
private func presentAnnotationNoteEditor(for highlight: RDEPUBHighlight) {
presentNoteEditor(quote: highlight.text, initialNote: highlight.note) { [weak self] note in
_ = self?.updateHighlightNote(id: highlight.id, note: note)
}
}
private func presentNoteEditor(quote: String, initialNote: String?, onSave: @escaping (String?) -> Void) {
guard let controller else { return }
let editor = RDEPUBAnnotationEditorViewController(
quote: quote,
initialNote: initialNote,
onSave: onSave
)
let navigationController = UINavigationController(rootViewController: editor)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
} }
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? { private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {

View File

@ -4,6 +4,8 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
private unowned let context: RDEPUBReaderContext private unowned let context: RDEPUBReaderContext
private var settingsTransitionDelegate: RDEPUBReaderSettingsTransitionDelegate?
init(context: RDEPUBReaderContext) { init(context: RDEPUBReaderContext) {
self.context = context self.context = context
} }
@ -122,9 +124,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple } controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
} }
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
}
settingsController.onDisplayTypeChange = { [weak controller] displayType in settingsController.onDisplayTypeChange = { [weak controller] displayType in
controller?.updateConfiguration { $0.displayType = displayType } controller?.updateConfiguration { $0.displayType = displayType }
} }
@ -137,8 +136,13 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
} }
let navigationController = UINavigationController(rootViewController: settingsController) let navigationController = UINavigationController(rootViewController: settingsController)
navigationController.modalPresentationStyle = .pageSheet let transitionDelegate = RDEPUBReaderSettingsTransitionDelegate()
navigationController.presentationController?.delegate = self settingsTransitionDelegate = transitionDelegate
navigationController.setNavigationBarHidden(true, animated: false)
navigationController.view.backgroundColor = .clear
navigationController.view.isOpaque = false
navigationController.modalPresentationStyle = .custom
navigationController.transitioningDelegate = transitionDelegate
controller.present(navigationController, animated: true) controller.present(navigationController, animated: true)
} }
@ -233,3 +237,77 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
return candidate return candidate
} }
} }
private final class RDEPUBReaderSettingsTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate {
func presentationController(
forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController
) -> UIPresentationController? {
RDEPUBReaderSettingsPresentationController(
presentedViewController: presented,
presenting: presenting ?? source
)
}
}
private final class RDEPUBReaderSettingsPresentationController: UIPresentationController {
private let dimmingView = UIControl()
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerView else { return .zero }
let bounds = containerView.bounds
let isLandscape = bounds.width > bounds.height
if isLandscape {
let width = min(390, max(300, bounds.width * 0.32))
let height = min(340, bounds.height - 32)
return CGRect(
x: bounds.maxX - width - 16,
y: bounds.midY - height / 2,
width: width,
height: height
)
}
let height = min(350, max(280, bounds.height * 0.40))
return CGRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
}
override func presentationTransitionWillBegin() {
guard let containerView else { return }
dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.18)
dimmingView.alpha = 0
dimmingView.addTarget(self, action: #selector(dismissPresentedController), for: .touchUpInside)
dimmingView.frame = containerView.bounds
containerView.insertSubview(dimmingView, at: 0)
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 1
})
}
override func dismissalTransitionWillBegin() {
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 0
})
}
override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()
dimmingView.frame = containerView?.bounds ?? .zero
presentedView?.frame = frameOfPresentedViewInContainerView
}
override func dismissalTransitionDidEnd(_ completed: Bool) {
super.dismissalTransitionDidEnd(completed)
if completed { dimmingView.removeFromSuperview() }
}
@objc private func dismissPresentedController() {
if let navigationController = presentedViewController as? UINavigationController,
let settingsController = navigationController.topViewController as? RDEPUBReaderSettingsViewController {
settingsController.notifyDismissalIfNeeded()
}
presentedViewController.dismiss(animated: true)
}
}

View File

@ -163,7 +163,11 @@ final class RDEPUBReaderContext {
} }
func currentPreferences(pageIndex: Int? = nil) -> RDEPUBPreferences { func currentPreferences(pageIndex: Int? = nil) -> RDEPUBPreferences {
environment.currentPreferences(configuration: configuration, pageIndex: pageIndex) var preferences = environment.currentPreferences(configuration: configuration, pageIndex: pageIndex)
if publication?.requiresSinglePagePresentation == true {
preferences.fixedLayoutSpreadMode = .never
}
return preferences
} }
func currentTextContentInsets(pageIndex: Int) -> UIEdgeInsets { func currentTextContentInsets(pageIndex: Int) -> UIEdgeInsets {

View File

@ -354,10 +354,6 @@ final class RDEPUBReaderPaginationCoordinator {
} }
} }
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool { private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
guard publication.spine.indices.contains(index) else { return false } guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index] let item = publication.spine[index]

View File

@ -256,8 +256,8 @@ final class RDEPUBReaderRuntime {
annotationCoordinator.presentAnnotationCreation() annotationCoordinator.presentAnnotationCreation()
} }
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) { func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect) annotationCoordinator.handleHighlightMenuAction(action, highlight: highlight)
} }
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {

View File

@ -1,30 +1,19 @@
import UIKit import UIKit
/// Compact reader settings drawer. It deliberately keeps the book visible and
/// exposes only the controls currently supported by the reader configuration.
final class RDEPUBReaderSettingsViewController: UIViewController { final class RDEPUBReaderSettingsViewController: UIViewController {
var onBrightnessChange: ((CGFloat) -> Void)? var onBrightnessChange: ((CGFloat) -> Void)?
var onFontSizeChange: ((CGFloat) -> Void)? var onFontSizeChange: ((CGFloat) -> Void)?
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)? var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
var onLineHeightChange: ((CGFloat) -> Void)? var onLineHeightChange: ((CGFloat) -> Void)?
var onColumnCountChange: ((Int) -> Void)?
var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)? var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
var onThemeChange: ((RDEPUBReaderTheme) -> Void)? var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
var onDismiss: (() -> Void)? var onDismiss: (() -> Void)?
private enum ThemePreset: Int, CaseIterable { private enum ThemePreset: Int, CaseIterable {
case light case light, yellow, green, pink, blue, dark
case yellow
case green
case pink
case blue
case dark
var title: String { var title: String {
switch self { switch self {
@ -49,50 +38,24 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
} }
} }
private let scrollView = UIScrollView() private let handleView = UIView()
private let closeButton = UIButton(type: .system)
private let contentStack: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 20
return stackView
}()
private let brightnessSlider = UISlider() private let brightnessSlider = UISlider()
private let fontSizeSlider = UISlider()
private let fontValueLabel = UILabel() private let fontSizeLabel = UILabel()
private let fontChoiceButton = UIButton(type: .system)
private let decreaseFontButton = UIButton(type: .system) private let lineHeightButton = UIButton(type: .system)
private let displayTypeButton = UIButton(type: .system)
private let increaseFontButton = UIButton(type: .system) private let themeStack = UIStackView()
private let fontChoiceControl = UISegmentedControl(items: RDEPUBReaderFontChoice.allCases.map(\.displayName))
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
private let themeStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .fillEqually
stackView.spacing = 12
return stackView
}()
private var themeButtons: [UIButton] = [] private var themeButtons: [UIButton] = []
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
private var currentConfiguration: RDEPUBReaderConfiguration private var currentConfiguration: RDEPUBReaderConfiguration
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
private var hasNotifiedDismissal = false
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) { init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
self.currentConfiguration = configuration currentConfiguration = configuration
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
brightnessSlider.value = Float(brightness) brightnessSlider.value = Float(brightness)
title = "样式设置"
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -101,262 +64,266 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
setupNavigationItems()
setupViews() setupViews()
syncControls() syncControls()
applyTheme(currentConfiguration.theme) applyTheme(currentConfiguration.theme)
} }
private func setupNavigationItems() { private func setupViews() {
let doneItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction)) view.accessibilityIdentifier = "epub.reader.settings.panel"
doneItem.accessibilityIdentifier = "epub.reader.settings.done" view.layer.cornerRadius = 24
navigationItem.rightBarButtonItem = doneItem view.layer.cornerCurve = .continuous
view.clipsToBounds = true
[handleView, closeButton, brightnessSlider, fontSizeSlider, fontSizeLabel,
fontChoiceButton, lineHeightButton, displayTypeButton, themeStack].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
view.addSubview($0)
} }
private func setupViews() { handleView.layer.cornerRadius = 3
view.addSubview(scrollView) closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
scrollView.accessibilityIdentifier = "epub.reader.settings.scroll" closeButton.accessibilityIdentifier = "epub.reader.settings.done"
scrollView.translatesAutoresizingMaskIntoConstraints = false closeButton.accessibilityLabel = "完成"
scrollView.addSubview(contentStack) closeButton.addTarget(self, action: #selector(doneAction), for: .touchUpInside)
contentStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20),
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24),
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
])
brightnessSlider.minimumValue = 0 brightnessSlider.minimumValue = 0
brightnessSlider.maximumValue = 1 brightnessSlider.maximumValue = 1
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness" brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged) brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold) fontSizeSlider.minimumValue = 12
fontValueLabel.textAlignment = .center fontSizeSlider.maximumValue = 36
fontValueLabel.accessibilityIdentifier = "epub.reader.settings.font.value" fontSizeSlider.accessibilityIdentifier = "epub.reader.settings.font.size"
fontValueLabel.setContentHuggingPriority(.required, for: .horizontal) fontSizeSlider.addTarget(self, action: #selector(fontSizeChanged(_:)), for: .valueChanged)
fontSizeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 14, weight: .semibold)
fontSizeLabel.textAlignment = .center
fontSizeLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
configureFontButton(decreaseFontButton, title: "A-") configurePill(fontChoiceButton, title: "字体")
configureFontButton(increaseFontButton, title: "A+") configurePill(lineHeightButton, title: "行距")
decreaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.decrease" configurePill(displayTypeButton, title: "翻页方式")
increaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.increase" fontChoiceButton.accessibilityIdentifier = "epub.reader.settings.font.choice"
decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside) lineHeightButton.accessibilityIdentifier = "epub.reader.settings.lineHeight"
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside) displayTypeButton.accessibilityIdentifier = "epub.reader.settings.displayType"
fontChoiceButton.addTarget(self, action: #selector(fontChoiceAction), for: .touchUpInside)
lineHeightControl.accessibilityIdentifier = "epub.reader.settings.lineHeight" lineHeightButton.addTarget(self, action: #selector(lineHeightAction), for: .touchUpInside)
fontChoiceControl.accessibilityIdentifier = "epub.reader.settings.font.choice" displayTypeButton.addTarget(self, action: #selector(displayTypeAction), for: .touchUpInside)
columnCountControl.accessibilityIdentifier = "epub.reader.settings.columns"
displayTypeControl.accessibilityIdentifier = "epub.reader.settings.displayType"
fontChoiceControl.addTarget(self, action: #selector(fontChoiceChanged(_:)), for: .valueChanged)
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged)
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
themeStack.axis = .horizontal
themeStack.distribution = .fillEqually
themeStack.spacing = 14
ThemePreset.allCases.forEach { preset in ThemePreset.allCases.forEach { preset in
let button = UIButton(type: .system) let button = UIButton(type: .system)
button.tag = preset.rawValue button.tag = preset.rawValue
button.layer.cornerRadius = 18
button.layer.borderWidth = 1.5
button.backgroundColor = preset.theme.contentBackgroundColor button.backgroundColor = preset.theme.contentBackgroundColor
button.layer.cornerRadius = 15
button.layer.borderWidth = 1
button.accessibilityLabel = preset.title button.accessibilityLabel = preset.title
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)" button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside) button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
themeButtons.append(button) themeButtons.append(button)
themeStackView.addArrangedSubview(button) themeStack.addArrangedSubview(button)
button.heightAnchor.constraint(equalToConstant: 30).isActive = true
}
let brightnessLabel = makeLabel("亮度")
let fontSizeTitle = makeLabel("字体大小")
let smallA = makeLabel("A", font: 18)
let largeA = makeLabel("A", font: 25)
[brightnessLabel, fontSizeTitle, smallA, largeA].forEach(view.addSubview)
let selectorStack = UIStackView(arrangedSubviews: [fontChoiceButton, lineHeightButton, displayTypeButton])
selectorStack.axis = .horizontal
selectorStack.distribution = .fillEqually
selectorStack.spacing = 10
selectorStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(selectorStack)
let dividerOne = makeDivider()
let dividerTwo = makeDivider()
[dividerOne, dividerTwo].forEach(view.addSubview)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
button.heightAnchor.constraint(equalToConstant: 36) handleView.topAnchor.constraint(equalTo: view.topAnchor, constant: 10),
handleView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
handleView.widthAnchor.constraint(equalToConstant: 42),
handleView.heightAnchor.constraint(equalToConstant: 5),
closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
closeButton.centerYAnchor.constraint(equalTo: handleView.centerYAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 32),
closeButton.heightAnchor.constraint(equalToConstant: 32),
brightnessLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
brightnessLabel.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 14),
brightnessSlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
brightnessSlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
brightnessSlider.topAnchor.constraint(equalTo: brightnessLabel.bottomAnchor, constant: 5),
dividerOne.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
dividerOne.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
dividerOne.topAnchor.constraint(equalTo: brightnessSlider.bottomAnchor, constant: 10),
fontSizeTitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
fontSizeTitle.topAnchor.constraint(equalTo: dividerOne.bottomAnchor, constant: 10),
smallA.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
smallA.centerYAnchor.constraint(equalTo: fontSizeSlider.centerYAnchor),
fontSizeSlider.leadingAnchor.constraint(equalTo: smallA.trailingAnchor, constant: 10),
fontSizeSlider.trailingAnchor.constraint(equalTo: largeA.leadingAnchor, constant: -10),
fontSizeSlider.topAnchor.constraint(equalTo: fontSizeTitle.bottomAnchor, constant: 4),
largeA.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
largeA.centerYAnchor.constraint(equalTo: fontSizeSlider.centerYAnchor),
fontSizeLabel.centerXAnchor.constraint(equalTo: fontSizeSlider.centerXAnchor),
fontSizeLabel.bottomAnchor.constraint(equalTo: fontSizeSlider.topAnchor, constant: -1),
dividerTwo.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
dividerTwo.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
dividerTwo.topAnchor.constraint(equalTo: fontSizeSlider.bottomAnchor, constant: 10),
selectorStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
selectorStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
selectorStack.topAnchor.constraint(equalTo: dividerTwo.bottomAnchor, constant: 12),
selectorStack.heightAnchor.constraint(equalToConstant: 42),
themeStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
themeStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
themeStack.topAnchor.constraint(equalTo: selectorStack.bottomAnchor, constant: 16),
themeStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -14)
]) ])
} }
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider)) private func makeLabel(_ text: String, font: CGFloat = 13) -> UILabel {
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow())) let label = UILabel()
contentStack.addArrangedSubview(makeSection(title: "字体", content: fontChoiceControl)) label.text = text
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl)) label.font = UIFont.systemFont(ofSize: font, weight: font > 16 ? .regular : .semibold)
contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl)) label.translatesAutoresizingMaskIntoConstraints = false
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl)) return label
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
} }
private func makeSection(title: String, content: UIView) -> UIView { private func makeDivider() -> UIView {
let container = UIStackView() let divider = UIView()
container.axis = .vertical divider.translatesAutoresizingMaskIntoConstraints = false
container.spacing = 10 divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
return divider
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
content.translatesAutoresizingMaskIntoConstraints = false
container.addArrangedSubview(titleLabel)
container.addArrangedSubview(content)
return container
} }
private func makeFontSizeRow() -> UIView { private func configurePill(_ button: UIButton, title: String) {
let stackView = UIStackView(arrangedSubviews: [decreaseFontButton, fontValueLabel, increaseFontButton])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.distribution = .fill
stackView.spacing = 12
NSLayoutConstraint.activate([
decreaseFontButton.widthAnchor.constraint(equalToConstant: 64),
increaseFontButton.widthAnchor.constraint(equalToConstant: 64),
decreaseFontButton.heightAnchor.constraint(equalToConstant: 36),
increaseFontButton.heightAnchor.constraint(equalToConstant: 36)
])
return stackView
}
private func configureFontButton(_ button: UIButton, title: String) {
button.setTitle(title, for: .normal) button.setTitle(title, for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold) button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
button.layer.cornerRadius = 18 button.layer.cornerRadius = 13
button.layer.borderWidth = 1
} }
private func syncControls() { private func syncControls() {
fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded())) fontSizeSlider.value = Float(currentConfiguration.fontSize)
fontChoiceControl.selectedSegmentIndex = RDEPUBReaderFontChoice.allCases.firstIndex(of: currentConfiguration.fontChoice) ?? 0 fontSizeLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
updateSelectorTitles()
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1 updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light)
lineHeightControl.selectedSegmentIndex = lineHeightIndex
columnCountControl.selectedSegmentIndex = currentConfiguration.numberOfColumns > 1 ? 1 : 0
switch currentConfiguration.displayType {
case .pageCurl:
displayTypeControl.selectedSegmentIndex = 0
case .horizontalScroll:
displayTypeControl.selectedSegmentIndex = 1
case .verticalScroll:
displayTypeControl.selectedSegmentIndex = 2
} }
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light private func updateSelectorTitles() {
updateThemeSelection(selectedPreset) fontChoiceButton.setTitle("\(currentConfiguration.fontChoice.displayName) ", for: .normal)
updateControlAccessibilityValues() let lineIndex = lineHeightValues.enumerated().min {
abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple)
}?.offset ?? 1
fontChoiceButton.accessibilityValue = currentConfiguration.fontChoice.displayName
let lineTitles = ["紧凑", "标准", "宽松"]
lineHeightButton.setTitle("\(lineTitles[lineIndex]) ", for: .normal)
lineHeightButton.accessibilityValue = lineTitles[lineIndex]
let displayTitle: String
switch currentConfiguration.displayType {
case .pageCurl: displayTitle = "仿真"
case .horizontalScroll: displayTitle = "横滑"
case .verticalScroll: displayTitle = "竖滑"
}
displayTypeButton.setTitle("\(displayTitle) ", for: .normal)
displayTypeButton.accessibilityValue = displayTitle
} }
private func applyTheme(_ theme: RDEPUBReaderTheme) { private func applyTheme(_ theme: RDEPUBReaderTheme) {
view.backgroundColor = theme.contentBackgroundColor view.backgroundColor = theme.toolBackgroundColor.withAlphaComponent(0.98)
scrollView.backgroundColor = theme.contentBackgroundColor let textColor = theme.toolControlTextColor
contentStack.arrangedSubviews handleView.backgroundColor = textColor.withAlphaComponent(0.45)
.compactMap { $0 as? UIStackView } closeButton.tintColor = textColor
.flatMap { $0.arrangedSubviews } view.subviews.compactMap { $0 as? UILabel }.forEach { $0.textColor = textColor }
.compactMap { $0 as? UILabel } view.subviews.filter { $0 !== handleView && $0 !== closeButton && !($0 is UISlider) && !($0 is UIStackView) && !($0 is UILabel) }
.forEach { $0.textColor = theme.contentTextColor } .forEach { $0.backgroundColor = textColor.withAlphaComponent(0.12) }
[fontChoiceButton, lineHeightButton, displayTypeButton].forEach {
fontValueLabel.textColor = theme.contentTextColor $0.setTitleColor(textColor, for: .normal)
[decreaseFontButton, increaseFontButton].forEach { button in $0.backgroundColor = textColor.withAlphaComponent(0.10)
button.setTitleColor(theme.toolControlTextColor, for: .normal)
button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor
button.backgroundColor = theme.toolBackgroundColor
} }
brightnessSlider.minimumTrackTintColor = .systemBlue
[fontChoiceControl, lineHeightControl, columnCountControl, displayTypeControl].forEach { control in fontSizeSlider.minimumTrackTintColor = .systemBlue
control.backgroundColor = theme.toolBackgroundColor brightnessSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
if #available(iOS 13.0, *) { fontSizeSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
} else {
control.tintColor = theme.toolControlTextColor
}
control.setTitleTextAttributes([.foregroundColor: theme.contentTextColor], for: .normal)
control.setTitleTextAttributes([.foregroundColor: theme.toolControlTextColor], for: .selected)
}
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light) updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
} }
private func updateThemeSelection(_ preset: ThemePreset) { private func updateThemeSelection(_ preset: ThemePreset) {
themeButtons.forEach { button in themeButtons.forEach { button in
let isSelected = button.tag == preset.rawValue let selected = button.tag == preset.rawValue
button.layer.borderWidth = isSelected ? 2 : 1 button.layer.borderWidth = selected ? 2.5 : 1
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor button.layer.borderColor = selected ? UIColor.systemBlue.cgColor : UIColor.white.withAlphaComponent(0.28).cgColor
button.accessibilityValue = isSelected ? "selected" : "unselected" button.accessibilityValue = selected ? "selected" : "unselected"
} }
} }
private func updateControlAccessibilityValues() { func notifyDismissalIfNeeded() {
fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex) guard !hasNotifiedDismissal else { return }
lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex) hasNotifiedDismissal = true
columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex) onDismiss?()
displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
} }
@objc private func doneAction() { @objc private func doneAction() {
dismiss(animated: true) { [weak self] in notifyDismissalIfNeeded()
self?.onDismiss?() dismiss(animated: true)
} }
@objc private func brightnessChanged(_ sender: UISlider) { onBrightnessChange?(CGFloat(sender.value)) }
@objc private func fontSizeChanged(_ sender: UISlider) {
let value = CGFloat(sender.value.rounded())
guard value != currentConfiguration.fontSize else { return }
currentConfiguration.fontSize = value
fontSizeLabel.text = String(Int(value))
onFontSizeChange?(value)
} }
@objc private func brightnessChanged(_ slider: UISlider) { @objc private func fontChoiceAction() {
onBrightnessChange?(CGFloat(slider.value)) let alert = UIAlertController(title: "字体", message: nil, preferredStyle: .actionSheet)
RDEPUBReaderFontChoice.allCases.forEach { choice in
alert.addAction(UIAlertAction(title: choice.displayName, style: .default) { [weak self] _ in
guard let self else { return }
self.currentConfiguration.fontChoice = choice
self.updateSelectorTitles()
self.onFontChoiceChange?(choice)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
} }
@objc private func decreaseFontAction() { @objc private func lineHeightAction() {
let nextValue = max(12, currentConfiguration.fontSize - 1) let alert = UIAlertController(title: "行距", message: nil, preferredStyle: .actionSheet)
guard nextValue != currentConfiguration.fontSize else { return } zip(["紧凑", "标准", "宽松"], lineHeightValues).forEach { title, value in
currentConfiguration.fontSize = nextValue alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
fontValueLabel.text = String(Int(nextValue.rounded())) guard let self else { return }
onFontSizeChange?(nextValue) self.currentConfiguration.lineHeightMultiple = value
self.updateSelectorTitles()
self.onLineHeightChange?(value)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
} }
@objc private func increaseFontAction() { @objc private func displayTypeAction() {
let nextValue = min(36, currentConfiguration.fontSize + 1) let alert = UIAlertController(title: "翻页方式", message: nil, preferredStyle: .actionSheet)
guard nextValue != currentConfiguration.fontSize else { return } [("仿真", RDEpubReaderView.DisplayType.pageCurl), ("横滑", .horizontalScroll), ("竖滑", .verticalScroll)].forEach { title, type in
currentConfiguration.fontSize = nextValue alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
fontValueLabel.text = String(Int(nextValue.rounded())) guard let self else { return }
onFontSizeChange?(nextValue) self.currentConfiguration.displayType = type
self.updateSelectorTitles()
self.onDisplayTypeChange?(type)
})
} }
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@objc private func fontChoiceChanged(_ control: UISegmentedControl) { present(alert, animated: true)
let choices = RDEPUBReaderFontChoice.allCases
let index = max(0, min(control.selectedSegmentIndex, choices.count - 1))
let choice = choices[index]
guard choice != currentConfiguration.fontChoice else { return }
currentConfiguration.fontChoice = choice
updateControlAccessibilityValues()
onFontChoiceChange?(choice)
}
@objc private func lineHeightChanged(_ control: UISegmentedControl) {
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
let value = lineHeightValues[index]
currentConfiguration.lineHeightMultiple = value
updateControlAccessibilityValues()
onLineHeightChange?(value)
}
@objc private func columnCountChanged(_ control: UISegmentedControl) {
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
currentConfiguration.numberOfColumns = numberOfColumns
updateControlAccessibilityValues()
onColumnCountChange?(numberOfColumns)
}
@objc private func displayTypeChanged(_ control: UISegmentedControl) {
let displayType: RDEpubReaderView.DisplayType
switch control.selectedSegmentIndex {
case 1:
displayType = .horizontalScroll
case 2:
displayType = .verticalScroll
default:
displayType = .pageCurl
}
currentConfiguration.displayType = displayType
updateControlAccessibilityValues()
onDisplayTypeChange?(displayType)
} }
@objc private func themeButtonAction(_ sender: UIButton) { @objc private func themeButtonAction(_ sender: UIButton) {

View File

@ -28,11 +28,19 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
) )
func textContentView( func textContentView(
_ contentView: RDEPUBTextContentView, _ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight, didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
sourceRect: CGRect highlight: RDEPUBHighlight
) )
} }
enum RDEPUBExistingHighlightMenuAction {
case copy
case createUnderline
case deleteUnderline
case annotate
case deleteAnnotation
}
extension RDEPUBTextContentViewDelegate { extension RDEPUBTextContentViewDelegate {
func textContentView( func textContentView(
_ contentView: RDEPUBTextContentView, _ contentView: RDEPUBTextContentView,
@ -60,6 +68,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
private var menuSelection: RDEPUBSelection? private var menuSelection: RDEPUBSelection?
/// The underline whose compact text menu is currently visible. Keeping this
/// separate from `currentSelection` means a marked paragraph is still fully
/// selectable with a long press.
private var menuHighlight: RDEPUBHighlight?
private var menuUnderlineHighlight: RDEPUBHighlight?
private var menuAnnotationHighlight: RDEPUBHighlight?
// M-15: Backing store for UIEditMenuInteraction (iOS 16+). // M-15: Backing store for UIEditMenuInteraction (iOS 16+).
// Stored as Any? to avoid @available on stored property restriction. // Stored as Any? to avoid @available on stored property restriction.
private var _editMenuInteraction: Any? private var _editMenuInteraction: Any?
@ -347,23 +364,52 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
} }
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
action == #selector(rd_copy(_:)) if menuHighlight != nil {
return action == #selector(rd_copy(_:))
|| action == #selector(rd_highlight(_:))
|| action == #selector(rd_deleteUnderline(_:))
|| action == #selector(rd_annotate(_:))
|| action == #selector(rd_deleteAnnotation(_:))
}
return action == #selector(rd_copy(_:))
|| action == #selector(rd_highlight(_:)) || action == #selector(rd_highlight(_:))
|| action == #selector(rd_annotate(_:)) || action == #selector(rd_annotate(_:))
} }
@objc func rd_copy(_ sender: Any?) { @objc func rd_copy(_ sender: Any?) {
if let highlight = menuHighlight {
performHighlightMenuAction(.copy, highlight: highlight)
return
}
performSelectionAction(.copy) performSelectionAction(.copy)
} }
@objc func rd_highlight(_ sender: Any?) { @objc func rd_highlight(_ sender: Any?) {
if let highlight = menuAnnotationHighlight {
performHighlightMenuAction(.createUnderline, highlight: highlight)
return
}
performSelectionAction(.highlight) performSelectionAction(.highlight)
} }
@objc func rd_annotate(_ sender: Any?) { @objc func rd_annotate(_ sender: Any?) {
if let highlight = menuHighlight {
performHighlightMenuAction(.annotate, highlight: highlight)
return
}
performSelectionAction(.annotate) performSelectionAction(.annotate)
} }
@objc func rd_deleteUnderline(_ sender: Any?) {
guard let highlight = menuUnderlineHighlight else { return }
performHighlightMenuAction(.deleteUnderline, highlight: highlight)
}
@objc func rd_deleteAnnotation(_ sender: Any?) {
guard let highlight = menuAnnotationHighlight else { return }
performHighlightMenuAction(.deleteAnnotation, highlight: highlight)
}
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
@ -855,51 +901,85 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
) )
return return
} }
guard let highlight = highlight(at: point), let highlights = highlights(at: point)
guard let highlight = highlights.first,
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else { let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate") RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView)) delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
return return
} }
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)") RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightIds=\(highlights.map(\.id).joined(separator: ","))")
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect) showHighlightMenu(for: highlights, sourceRect: sourceRect)
}
private func showHighlightMenu(for highlights: [RDEPUBHighlight], sourceRect: CGRect) {
// A short tap opens the compact system menu. Long-press selection is
// intentionally handled by the separate recognizer, which the tap
// recognizer already waits on, so text inside a marking remains
// selectable.
let underline = highlights.first { !$0.isAnnotationOnly }
let annotation = highlights.first { $0.isAnnotationOnly }
?? highlights.first { $0.hasNote }
menuHighlight = annotation ?? underline
menuUnderlineHighlight = underline
menuAnnotationHighlight = annotation
guard menuHighlight != nil else { return }
becomeFirstResponder()
let menuController = UIMenuController.shared
var items = [UIMenuItem(title: "复制", action: #selector(rd_copy(_:)))]
if underline != nil {
items.append(UIMenuItem(title: "删除划线", action: #selector(rd_deleteUnderline(_:))))
} else if annotation != nil {
items.append(UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))))
}
if let annotation, annotation.hasNote {
items.append(UIMenuItem(title: "删除注释", action: #selector(rd_deleteAnnotation(_:))))
} else if let underline, !underline.hasNote {
items.append(UIMenuItem(title: "注释", action: #selector(rd_annotate(_:))))
}
menuController.menuItems = items
menuController.setTargetRect(sourceRect, in: self)
menuController.setMenuVisible(true, animated: true)
}
private func performHighlightMenuAction(
_ action: RDEPUBExistingHighlightMenuAction,
highlight: RDEPUBHighlight
) {
menuHighlight = nil
menuUnderlineHighlight = nil
menuAnnotationHighlight = nil
UIMenuController.shared.setMenuVisible(false, animated: true)
delegate?.textContentView(self, didRequestHighlightMenuAction: action, highlight: highlight)
} }
private func showSelectionMenuIfNeeded() { private func showSelectionMenuIfNeeded() {
menuHighlight = nil
menuUnderlineHighlight = nil
menuAnnotationHighlight = nil
guard currentSelection != nil, guard currentSelection != nil,
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController), let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
!targetRect.isEmpty else { !targetRect.isEmpty else {
return return
} }
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect) let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
becomeFirstResponder()
let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
DispatchQueue.main.async { [weak self, weak interaction] in
guard let self, self.currentSelection != nil else { return }
interaction?.presentEditMenu(with: config)
}
} else {
becomeFirstResponder() becomeFirstResponder()
let menuController = UIMenuController.shared let menuController = UIMenuController.shared
menuController.menuItems = [ menuController.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))), UIMenuItem(title: "复制", action: #selector(rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))), UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:))) UIMenuItem(title: "注释", action: #selector(rd_annotate(_:)))
] ]
menuController.setTargetRect(resolvedTargetRect, in: self) menuController.setTargetRect(resolvedTargetRect, in: self)
menuController.setMenuVisible(true, animated: true) menuController.setMenuVisible(true, animated: true)
} }
}
private func hideSelectionMenu() { private func hideSelectionMenu() {
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction { menuHighlight = nil
interaction.dismissMenu() menuUnderlineHighlight = nil
} else { menuAnnotationHighlight = nil
UIMenuController.shared.setMenuVisible(false, animated: true) UIMenuController.shared.setMenuVisible(false, animated: true)
} }
}
private func updateSelectionLoupe(for point: CGPoint) { private func updateSelectionLoupe(for point: CGPoint) {
#if canImport(DTCoreText) #if canImport(DTCoreText)
@ -937,7 +1017,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
} }
private func highlight(at point: CGPoint) -> RDEPUBHighlight? { private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
guard let page = currentPage else { return nil } highlights(at: point).first
}
private func highlights(at point: CGPoint) -> [RDEPUBHighlight] {
guard let page = currentPage else { return [] }
let matches = currentHighlights.filter { highlight in let matches = currentHighlights.filter { highlight in
guard highlight.location.href == page.href, guard highlight.location.href == page.href,
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
@ -952,11 +1036,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
return matches.sorted { lhs, rhs in return matches.sorted { lhs, rhs in
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
if lhs.hasNote != rhs.hasNote { if lhsRange != rhsRange {
return lhs.hasNote && !rhs.hasNote // In an overlap, the narrower marking is the one the reader
} // most likely meant to touch (e.g. underline A inside note B).
return lhsRange < rhsRange return lhsRange < rhsRange
}.first }
return lhs.createdAt > rhs.createdAt
}
} }
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? { private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
@ -1133,9 +1219,9 @@ extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
suggestedActions: [UIMenuElement] suggestedActions: [UIMenuElement]
) -> UIMenu? { ) -> UIMenu? {
UIMenu(children: [ UIMenu(children: [
UICommand(title: "拷贝", action: #selector(rd_copy(_:))), UICommand(title: "复制", action: #selector(rd_copy(_:))),
UICommand(title: "高亮", action: #selector(rd_highlight(_:))), UICommand(title: "划线", action: #selector(rd_highlight(_:))),
UICommand(title: "", action: #selector(rd_annotate(_:))) UICommand(title: "", action: #selector(rd_annotate(_:)))
]) ])
} }

View File

@ -127,14 +127,19 @@ final class RDEpubReaderPreloadController {
return preloaded return preloaded
} }
func updatePageVisibility(visiblePageNumbers: Set<Int>) { func updatePageVisibility(visibleViewIdentifiers: Set<ObjectIdentifier>) {
var notifiedViews = Set<ObjectIdentifier>()
let cachedViews = pageCurlCachedViews.merging(preloadedPageViews) { current, _ in current } let cachedViews = pageCurlCachedViews.merging(preloadedPageViews) { current, _ in current }
for (pageNumber, view) in cachedViews { var notifiedViews = Set<ObjectIdentifier>()
// Cache entries are never the authority for activation: UIPageViewController
// can create two views for the same page number. Keep every non-winning
// instance suspended; the reader activates only its actually attached view.
for view in cachedViews.values {
let identifier = ObjectIdentifier(view) let identifier = ObjectIdentifier(view)
guard notifiedViews.insert(identifier).inserted else { continue } guard !visibleViewIdentifiers.contains(identifier),
notifiedViews.insert(identifier).inserted else { continue }
(view as? RDEpubReaderPageVisibilityHandling)? (view as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageNumber)) .readerPageVisibilityDidChange(isVisible: false)
} }
} }
@ -147,7 +152,7 @@ final class RDEpubReaderPreloadController {
) { ) {
refreshCacheSignatureIfNeeded(environment) refreshCacheSignatureIfNeeded(environment)
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment) let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment)
let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment)) let keepSet = Set(targets).union(spreadPageNumbers(startingAt: pageNum, environment: environment))
trimCachedPageViews(keeping: keepSet) trimCachedPageViews(keeping: keepSet)
guard !targets.isEmpty else { return } guard !targets.isEmpty else { return }
@ -194,6 +199,8 @@ final class RDEpubReaderPreloadController {
"cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))" "cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))"
) )
} }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment) let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
if shouldCacheContentView { if shouldCacheContentView {
preloadedPageViews[targetPage] = contentView preloadedPageViews[targetPage] = contentView
@ -309,10 +316,6 @@ final class RDEpubReaderPreloadController {
).left ).left
} }
private func visiblePageNumbers(for anchorPage: Int, environment: Environment) -> Set<Int> {
spreadPageNumbers(startingAt: anchorPage, environment: environment)
}
private func detachedReusablePageView(for pageNum: Int) -> UIView? { private func detachedReusablePageView(for pageNum: Int) -> UIView? {
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) { if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
preloaded.removeFromSuperview() preloaded.removeFromSuperview()
@ -351,6 +354,8 @@ final class RDEpubReaderPreloadController {
// //
// deinit // deinit
let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView
(view as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
view.removeFromSuperview() view.removeFromSuperview()
if !isHeldOutsidePreloadHost { if !isHeldOutsidePreloadHost {
(view as? RDEpubReaderPageResourceReleasing)?.releaseResources() (view as? RDEpubReaderPageResourceReleasing)?.releaseResources()

View File

@ -11,8 +11,11 @@ extension RDEpubReaderView: UICollectionViewDataSource, RDEpubReaderFlowLayoutDe
let reusableView = cell.containerView ?? preloadedView let reusableView = cell.containerView ?? preloadedView
let contentView = contentViewForPage(indexPath.row, reusableView: reusableView) let contentView = contentViewForPage(indexPath.row, reusableView: reusableView)
(contentView as? RDEpubReaderPageVisibilityHandling)? (contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(indexPath.row)) .readerPageVisibilityDidChange(isVisible: false)
cell.containerView = contentView cell.containerView = contentView
DispatchQueue.main.async { [weak self] in
self?.updatePageContentVisibility()
}
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll { if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1) cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)

View File

@ -96,7 +96,28 @@ extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewController
} }
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
let resolvedPage = (pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.pageNum ?? -1 let visibleControllers = pageViewController.viewControllers ?? []
let visibleControllerIdentifiers = Set(visibleControllers.map(ObjectIdentifier.init))
let transitionControllers = previousViewControllers + [
willPreviousTransitionToViewController,
willNextTransitionToViewController,
willTransitionToViewController
].compactMap { $0 }
var hiddenContentViews = Set<ObjectIdentifier>()
// UIPageViewController can ask its data source for a second instance of
// the same page and leave the first one outside our cache dictionaries.
// Explicitly stop every controller that did not win the transition so
// an orphaned Hype WebView cannot keep narrating in the background.
for controller in transitionControllers where !visibleControllerIdentifiers.contains(ObjectIdentifier(controller)) {
guard let childController = controller as? RDEpubReaderPageChildViewController,
let contentView = childController.contentView else { continue }
let identifier = ObjectIdentifier(contentView)
guard hiddenContentViews.insert(identifier).inserted else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
}
if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController { if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController {
let pn = firstVC.pageNum let pn = firstVC.pageNum
if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum { if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum {
@ -105,6 +126,14 @@ extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewController
} }
} }
// On cancellation currentPage does not change, so its didSet cannot
// restore visibility. Reassert the actual controllers in both paths.
for case let childController as RDEpubReaderPageChildViewController in visibleControllers {
guard let contentView = childController.contentView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(childController.pageNum))
}
predictedPageDirection = nil predictedPageDirection = nil
finishPageCurlTransition() finishPageCurlTransition()
} }

View File

@ -527,10 +527,13 @@ public class RDEpubReaderView: UIView {
let pageView = preloadController.pageViewForDisplay( let pageView = preloadController.pageViewForDisplay(
pageNum: pageNum, pageNum: pageNum,
environment: preloadEnvironment, environment: preloadEnvironment,
contentViewProvider: pageContentViewForDisplay(pageNum:reusableView:) contentViewProvider: contentViewForPage(_:reusableView:)
) )
// A data-source request is only a candidate. UIPageViewController may
// ask for duplicate instances of the current page; activation happens
// after the winning controller is actually installed.
(pageView as? RDEpubReaderPageVisibilityHandling)? (pageView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(pageNum)) .readerPageVisibilityDidChange(isVisible: false)
return pageView return pageView
} }
@ -551,10 +554,6 @@ public class RDEpubReaderView: UIView {
return contentViewForPage(pageNum, reusableView: reusableView) return contentViewForPage(pageNum, reusableView: reusableView)
} }
private func pageContentViewForDisplay(pageNum: Int, reusableView: UIView?) -> UIView? {
return contentViewForPage(pageNum, reusableView: reusableView)
}
func isReaderPageVisible(_ pageNum: Int) -> Bool { func isReaderPageVisible(_ pageNum: Int) -> Bool {
guard currentPage >= 0 else { return pageNum == 0 } guard currentPage >= 0 else { return pageNum == 0 }
if landscapeDualPageEnabled && isLandscape { if landscapeDualPageEnabled && isLandscape {
@ -564,7 +563,7 @@ public class RDEpubReaderView: UIView {
return pageNum == currentPage return pageNum == currentPage
} }
private func updatePageContentVisibility() { func updatePageContentVisibility() {
var visiblePageNumbers = Set<Int>() var visiblePageNumbers = Set<Int>()
if currentPage >= 0 { if currentPage >= 0 {
visiblePageNumbers.insert(currentPage) visiblePageNumbers.insert(currentPage)
@ -574,20 +573,57 @@ public class RDEpubReaderView: UIView {
} }
} }
preloadController.updatePageVisibility(visiblePageNumbers: visiblePageNumbers) let pageControllers: [RDEpubReaderPageChildViewController]
let visibleCellContents: [(pageNumber: Int, contentView: UIView)]
for viewController in pageViewController.viewControllers ?? [] { if currentDisplayType == .pageCurl {
guard let pageController = viewController as? RDEpubReaderPageChildViewController, pageControllers = (pageViewController.viewControllers ?? []).compactMap {
let contentView = pageController.contentView else { continue } $0 as? RDEpubReaderPageChildViewController
(contentView as? RDEpubReaderPageVisibilityHandling)? }
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageController.pageNum)) visibleCellContents = []
} else {
pageControllers = []
visibleCellContents = collectionView.indexPathsForVisibleItems.compactMap { indexPath in
guard let cell = collectionView.cellForItem(at: indexPath) as? RDEpubReaderContentCell,
let contentView = cell.containerView else { return nil }
return (indexPath.item, contentView)
}
} }
for indexPath in collectionView.indexPathsForVisibleItems { for pageController in pageControllers where !visiblePageNumbers.contains(pageController.pageNum) {
guard let cell = collectionView.cellForItem(at: indexPath) as? RDEpubReaderContentCell, guard let contentView = pageController.contentView else { continue }
let contentView = cell.containerView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)? (contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(indexPath.item)) .readerPageVisibilityDidChange(isVisible: false)
}
for entry in visibleCellContents where !visiblePageNumbers.contains(entry.pageNumber) {
(entry.contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
}
// Suspend every cached non-winner before the attached winner below is
// activated. Object identity matters because a page number can have
// multiple UIPageViewController candidates.
let visibleViewIdentifiers = Set(
pageControllers.compactMap { pageController -> ObjectIdentifier? in
guard visiblePageNumbers.contains(pageController.pageNum),
let contentView = pageController.contentView else { return nil }
return ObjectIdentifier(contentView)
} + visibleCellContents.compactMap { entry -> ObjectIdentifier? in
guard visiblePageNumbers.contains(entry.pageNumber) else { return nil }
return ObjectIdentifier(entry.contentView)
}
)
preloadController.updatePageVisibility(visibleViewIdentifiers: visibleViewIdentifiers)
for pageController in pageControllers where visiblePageNumbers.contains(pageController.pageNum) {
guard let contentView = pageController.contentView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: true)
}
for entry in visibleCellContents where visiblePageNumbers.contains(entry.pageNumber) {
(entry.contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: true)
} }
} }
@ -629,6 +665,11 @@ public class RDEpubReaderView: UIView {
} }
private func detachPageViewControllerIfNeeded() { private func detachPageViewControllerIfNeeded() {
for case let childController as RDEpubReaderPageChildViewController in pageViewController.viewControllers ?? [] {
guard let contentView = childController.contentView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
}
if pageViewController.parent != nil { if pageViewController.parent != nil {
pageViewController.willMove(toParent: nil) pageViewController.willMove(toParent: nil)
} }
@ -882,6 +923,17 @@ public class RDEpubReaderView: UIView {
} }
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
attachPageViewControllerIfNeeded() attachPageViewControllerIfNeeded()
// Programmatic setViewControllers transitions do not call the
// UIPageViewControllerDelegate didFinishAnimating callback. Stop
// the outgoing controllers explicitly before replacing them, even
// when a duplicate page instance has fallen out of our cache map.
for case let childController as RDEpubReaderPageChildViewController in pageViewController.viewControllers ?? [] {
guard let contentView = childController.contentView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
}
if isDualPage { if isDualPage {
let pair = dualPagePair(for: safePageNum) let pair = dualPagePair(for: safePageNum)
let leftContent = pageViewForDisplay(pageNum: pair.left) let leftContent = pageViewForDisplay(pageNum: pair.left)
@ -903,7 +955,11 @@ public class RDEpubReaderView: UIView {
self.finishPageCurlTransition() self.finishPageCurlTransition()
} }
} }
let pageChanged = currentPage != pair.left
currentPage = pair.left currentPage = pair.left
if !pageChanged {
updatePageContentVisibility()
}
primePageCache(around: pair.left, preferredForward: predictedPageDirection) primePageCache(around: pair.left, preferredForward: predictedPageDirection)
} else { } else {
let contentView = pageViewForDisplay(pageNum: safePageNum) let contentView = pageViewForDisplay(pageNum: safePageNum)
@ -913,7 +969,11 @@ public class RDEpubReaderView: UIView {
guard let self else { return } guard let self else { return }
self.finishPageCurlTransition() self.finishPageCurlTransition()
} }
let pageChanged = currentPage != safePageNum
currentPage = safePageNum currentPage = safePageNum
if !pageChanged {
updatePageContentVisibility()
}
primePageCache(around: safePageNum, preferredForward: predictedPageDirection) primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
} }
default: default:
@ -921,7 +981,11 @@ public class RDEpubReaderView: UIView {
collectionView.layoutIfNeeded() collectionView.layoutIfNeeded()
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated) collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated)
let pageChanged = currentPage != safePageNum
currentPage = safePageNum currentPage = safePageNum
if !pageChanged {
updatePageContentVisibility()
}
primePageCache(around: safePageNum, preferredForward: predictedPageDirection) primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
} }
} }