feat: improve epub reader controls and annotations
This commit is contained in:
parent
063a493b18
commit
8ccb7157b2
3170
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
3170
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -54,7 +54,6 @@ enum IDs {
|
||||
static let settingsFontValue = "epub.reader.settings.font.value"
|
||||
static let settingsFontChoice = "epub.reader.settings.font.choice"
|
||||
static let settingsLineHeight = "epub.reader.settings.lineHeight"
|
||||
static let settingsColumns = "epub.reader.settings.columns"
|
||||
static let settingsDisplayType = "epub.reader.settings.displayType"
|
||||
static let settingsDone = "epub.reader.settings.done"
|
||||
static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" }
|
||||
|
||||
@ -28,6 +28,7 @@ struct DemoReaderState {
|
||||
|
||||
var isOpened: Bool { fields["reader"] == "opened" }
|
||||
var page: Int? { fields["page"].flatMap(Int.init) }
|
||||
var pagesPerScreen: Int? { fields["pagesPerScreen"].flatMap(Int.init) }
|
||||
var display: String? { fields["display"] }
|
||||
var toolbar: String? { fields["toolbar"] }
|
||||
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
|
||||
|
||||
@ -23,6 +23,11 @@ final class FixedLayoutRotationTests: XCTestCase {
|
||||
for _ in 0..<3 {
|
||||
XCUIDevice.shared.orientation = .landscapeLeft
|
||||
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
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
||||
}
|
||||
@ -30,4 +35,29 @@ final class FixedLayoutRotationTests: XCTestCase {
|
||||
XCTAssertEqual(app.state, .runningForeground, "反复旋转后 app 已退出(疑似崩溃)")
|
||||
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, "返回缓存页后阅读区域不应消失")
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,9 +19,9 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightMenuItem = app.menuItems["划线"]
|
||||
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 }
|
||||
|
||||
@ -42,7 +42,7 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightMenuItem = app.menuItems["划线"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
@ -63,7 +63,7 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightMenuItem = app.menuItems["划线"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
@ -102,7 +102,7 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightMenuItem = app.menuItems["划线"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
@ -112,15 +112,14 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
XCTAssertTrue(highlightsTable.waitForExistence(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 deleteHighlightButton = app.buttons["删除高亮"].firstMatch
|
||||
if deleteButton.waitForExistence(timeout: 3) {
|
||||
deleteButton.tap()
|
||||
} else if deleteHighlightButton.waitForExistence(timeout: 1) {
|
||||
deleteHighlightButton.tap()
|
||||
}
|
||||
let deleteControl = highlightsTable.buttons["删除"].firstMatch
|
||||
XCTAssertTrue(deleteControl.waitForExistence(timeout: 3), "编辑状态应显示删除控件")
|
||||
deleteControl.tap()
|
||||
app.buttons["删除"].lastMatch.tap()
|
||||
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
||||
}
|
||||
|
||||
@ -136,8 +136,8 @@ final class LocationPersistenceTests: XCTestCase {
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
XCTAssertTrue(app.menuItems["高亮"].waitForExistence(timeout: 3))
|
||||
app.menuItems["高亮"].tap()
|
||||
XCTAssertTrue(app.menuItems["划线"].waitForExistence(timeout: 3))
|
||||
app.menuItems["划线"].tap()
|
||||
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
@ -167,7 +167,7 @@ final class LocationPersistenceTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightItem = app.menuItems["高亮"]
|
||||
let highlightItem = app.menuItems["划线"]
|
||||
XCTAssertTrue(highlightItem.waitForExistence(timeout: 3), "应出现高亮菜单")
|
||||
highlightItem.tap()
|
||||
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||
|
||||
@ -22,7 +22,7 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
|
||||
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightMenuItem = app.menuItems["划线"]
|
||||
let highlightButton = app.buttons[IDs.readerSelectionHighlight]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) {
|
||||
highlightMenuItem.tap()
|
||||
@ -65,7 +65,7 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
|
||||
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
||||
|
||||
let annotateMenuItem = app.menuItems["批注"]
|
||||
let annotateMenuItem = app.menuItems["注释"]
|
||||
XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 3), "选中文本后未出现批注菜单")
|
||||
annotateMenuItem.tap()
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
// 点击批注菜单项
|
||||
let annotateItem = app.menuItems["批注"]
|
||||
let annotateItem = app.menuItems["注释"]
|
||||
guard annotateItem.waitForExistence(timeout: 3) else {
|
||||
throw XCTSkip("批注菜单项不存在")
|
||||
}
|
||||
@ -56,7 +56,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
||||
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
let annotateItem = app.menuItems["批注"]
|
||||
let annotateItem = app.menuItems["注释"]
|
||||
guard annotateItem.waitForExistence(timeout: 3) else {
|
||||
throw XCTSkip("批注菜单项不存在")
|
||||
}
|
||||
@ -101,7 +101,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
let highlightItem = app.menuItems["高亮"]
|
||||
let highlightItem = app.menuItems["划线"]
|
||||
guard highlightItem.waitForExistence(timeout: 3) else {
|
||||
throw XCTSkip("高亮菜单项不存在")
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ final class SelectionMenuTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
|
||||
XCTAssertTrue(app.menuItems["复制"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
|
||||
}
|
||||
|
||||
func testSelectionMenuShowsAnnotateOption() throws {
|
||||
@ -32,7 +32,7 @@ final class SelectionMenuTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
XCTAssertTrue(app.menuItems["批注"].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
|
||||
XCTAssertTrue(app.menuItems["注释"].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
|
||||
}
|
||||
|
||||
func testTapBlankAreaClearsSelection() throws {
|
||||
@ -46,14 +46,14 @@ final class SelectionMenuTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
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
|
||||
if contentArea.waitForExistence(timeout: 3) {
|
||||
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, "点击空白区域后选区菜单应消失")
|
||||
}
|
||||
|
||||
@ -68,7 +68,7 @@ final class SelectionMenuTests: XCTestCase {
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let copyMenuItem = app.menuItems["拷贝"]
|
||||
let copyMenuItem = app.menuItems["复制"]
|
||||
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
|
||||
|
||||
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
|
||||
|
||||
@ -44,24 +44,6 @@ final class SettingsEffectTests: XCTestCase {
|
||||
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 {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
@ -36,18 +36,6 @@ final class SettingsExtendedTests: XCTestCase {
|
||||
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() {
|
||||
openSettings()
|
||||
let displayType = app.segmentedControls[IDs.settingsDisplayType]
|
||||
@ -73,7 +61,6 @@ final class SettingsExtendedTests: XCTestCase {
|
||||
|
||||
XCTAssertTrue(app.sliders[IDs.settingsBrightness].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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,6 +110,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
|
||||
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 init(
|
||||
@ -118,9 +122,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
style: RDEPUBHighlightStyle = .highlight,
|
||||
style: RDEPUBHighlightStyle = .underline,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil,
|
||||
isAnnotationOnly: Bool = false,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
@ -131,6 +136,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
self.style = style
|
||||
self.color = color
|
||||
self.note = note?.nilIfEmpty
|
||||
self.isAnnotationOnly = isAnnotationOnly
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
@ -152,6 +158,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
case style
|
||||
case color
|
||||
case note
|
||||
case isAnnotationOnly
|
||||
case createdAt
|
||||
}
|
||||
|
||||
@ -164,12 +171,13 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
||||
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
||||
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
||||
style = decodedStyle
|
||||
style = decodedStyle == .highlight ? .underline : decodedStyle
|
||||
} else {
|
||||
style = .highlight
|
||||
style = .underline
|
||||
}
|
||||
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
||||
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()
|
||||
}
|
||||
|
||||
@ -183,6 +191,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
try container.encode(style, forKey: .style)
|
||||
try container.encode(color, forKey: .color)
|
||||
try container.encodeIfPresent(note, forKey: .note)
|
||||
try container.encode(isAnnotationOnly, forKey: .isAnnotationOnly)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
|
||||
|
||||
@ -36,6 +36,13 @@ public final class RDEPUBPublication {
|
||||
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 {
|
||||
metadata.readingProgression
|
||||
}
|
||||
|
||||
@ -56,9 +56,9 @@ extension RDEPUBWebView {
|
||||
webView._editMenuInteraction = interaction
|
||||
} else {
|
||||
UIMenuController.shared.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||
UIMenuItem(title: "复制", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||
UIMenuItem(title: "划线", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "注释", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||
]
|
||||
}
|
||||
webView.navigationDelegate = self
|
||||
@ -89,6 +89,11 @@ extension RDEPUBWebView {
|
||||
self.schemeHandler = schemeHandler
|
||||
self.webView = webView
|
||||
self.configuredPublicationKey = publicationKey
|
||||
appliedNativeMediaPlaybackSuspended = false
|
||||
isNativeMediaPlaybackOperationInFlight = false
|
||||
nativeMediaPlaybackOperationGeneration &+= 1
|
||||
nativeMediaPlaybackRequests.removeAll()
|
||||
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
|
||||
}
|
||||
|
||||
@ -103,6 +108,7 @@ extension RDEPUBWebView {
|
||||
|
||||
func teardownWebView() {
|
||||
cancelFixedLayoutReadyFallback()
|
||||
RDEPUBReaderMediaPlaybackCoordinator.shared.remove(self)
|
||||
if let webView {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||
}
|
||||
@ -119,6 +125,10 @@ extension RDEPUBWebView {
|
||||
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
|
||||
webView?.removeFromSuperview()
|
||||
webView = nil
|
||||
finishAllNativeMediaPlaybackRequests()
|
||||
isWaitingForReaderPageMediaNavigation = false
|
||||
pendingReaderPageMediaNavigation = nil
|
||||
isReaderPageMediaActivationBlocked = false
|
||||
schemeHandler = nil
|
||||
configuredPublicationKey = 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 {
|
||||
|
||||
/// Installed in every frame before EPUB scripts run. Hidden pages start in
|
||||
/// a suspended state, including audio created with `new Audio()` that is
|
||||
/// not attached to the DOM (as used by Tumult Hype publications).
|
||||
/// a suspended state for both HTML media and Web Audio. Tumult Hype uses
|
||||
/// `AudioContext`/`AudioBufferSourceNode` for this publication, so handling
|
||||
/// only `<audio>` and `new Audio()` leaves narration running on cached pages.
|
||||
static let readerPageMediaLifecycleUserScript = #"""
|
||||
(() => {
|
||||
if (window.__rdReaderMediaLifecycleInstalled) return;
|
||||
@ -155,10 +255,17 @@ extension RDEPUBWebView {
|
||||
|
||||
let isActive = false;
|
||||
const trackedMedia = new Set();
|
||||
const trackedAudioContexts = new Set();
|
||||
const lifecycleSuspendedAudioContexts = new Set();
|
||||
const command = "rd-reader-page-visibility";
|
||||
const frameReadyCommand = "rd-reader-frame-media-ready";
|
||||
const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype;
|
||||
const nativePlay = mediaPrototype && mediaPrototype.play;
|
||||
|
||||
const ignoreRejection = result => {
|
||||
if (result && result.catch) result.catch(() => {});
|
||||
};
|
||||
|
||||
const register = media => {
|
||||
if (media) trackedMedia.add(media);
|
||||
return media;
|
||||
@ -169,17 +276,47 @@ extension RDEPUBWebView {
|
||||
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 => {
|
||||
isActive = !!active;
|
||||
collectDocumentMedia();
|
||||
trackedMedia.forEach(media => {
|
||||
if (isActive) {
|
||||
if (media.__rdReaderReplayWhenVisible) {
|
||||
media.__rdReaderReplayWhenVisible = false;
|
||||
try {
|
||||
media.currentTime = 0;
|
||||
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 (_) {}
|
||||
}
|
||||
} else {
|
||||
@ -191,6 +328,7 @@ extension RDEPUBWebView {
|
||||
}
|
||||
}
|
||||
});
|
||||
trackedAudioContexts.forEach(updateAudioContext);
|
||||
};
|
||||
|
||||
if (mediaPrototype && nativePlay) {
|
||||
@ -218,34 +356,212 @@ extension RDEPUBWebView {
|
||||
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 => {
|
||||
const message = event.data;
|
||||
if (!message || message.type !== command) return;
|
||||
setActive(message.active);
|
||||
for (let index = 0; index < window.frames.length; index += 1) {
|
||||
try { window.frames[index].postMessage(message, "*"); } catch (_) {}
|
||||
if (!message) return;
|
||||
if (message.type === frameReadyCommand) {
|
||||
if (event.source) sendVisibility(event.source);
|
||||
return;
|
||||
}
|
||||
if (message.type !== command) return;
|
||||
setActive(message.active);
|
||||
broadcastVisibility();
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => setActive(isActive));
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setActive(isActive);
|
||||
broadcastVisibility();
|
||||
});
|
||||
if (window.MutationObserver) {
|
||||
new MutationObserver(() => setActive(isActive)).observe(document, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
if (window.parent !== window) {
|
||||
try { window.parent.postMessage({ type: frameReadyCommand }, "*"); } catch (_) {}
|
||||
}
|
||||
})();
|
||||
"""#
|
||||
|
||||
func setReaderPageVisible(_ isVisible: Bool) {
|
||||
let wasVisible = isReaderPageVisible
|
||||
isReaderPageVisible = isVisible
|
||||
|
||||
if !isVisible {
|
||||
if wasVisible,
|
||||
hasReaderPageBeenVisible,
|
||||
currentRenderRequest?.isFixedLayout == true {
|
||||
needsReaderPagePlaybackRestart = true
|
||||
}
|
||||
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() {
|
||||
guard let webView else { return }
|
||||
let active = isReaderPageVisible ? "true" : "false"
|
||||
let script = "window.postMessage({type: 'rd-reader-page-visibility', active: \(active)}, '*');"
|
||||
webView.evaluateJavaScript(script, completionHandler: nil)
|
||||
let isVisible = isReaderPageVisible && !isReaderPageMediaActivationBlocked
|
||||
if !isVisible {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ extension RDEPUBWebView {
|
||||
|
||||
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)")
|
||||
webView.loadHTMLString(
|
||||
pendingReaderPageMediaNavigation = webView.loadHTMLString(
|
||||
html,
|
||||
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
|
||||
)
|
||||
|
||||
@ -31,20 +31,33 @@ extension RDEPUBWebView: WKNavigationDelegate {
|
||||
|
||||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
||||
applyReaderPageMediaVisibility()
|
||||
guard readerPageMediaNavigationDidFinish(navigation) else { return }
|
||||
applyPresentation()
|
||||
}
|
||||
|
||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
|
||||
@ -74,9 +74,9 @@ extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate {
|
||||
menuFor configuration: UIEditMenuConfiguration
|
||||
) -> UIMenu {
|
||||
UIMenu(children: [
|
||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
UICommand(title: "复制", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "划线", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "注释", action: #selector(rd_annotate(_:)))
|
||||
])
|
||||
}
|
||||
|
||||
@ -138,6 +138,24 @@ public final class RDEPUBWebView: UIView {
|
||||
|
||||
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 fixedLayoutReadyWorkItem: DispatchWorkItem?
|
||||
@ -189,6 +207,11 @@ public final class RDEPUBWebView: UIView {
|
||||
fixedSpread = nil
|
||||
isFixedLayout = false
|
||||
isReaderPageVisible = true
|
||||
hasReaderPageBeenVisible = false
|
||||
needsReaderPagePlaybackRestart = false
|
||||
isWaitingForReaderPageMediaNavigation = false
|
||||
pendingReaderPageMediaNavigation = nil
|
||||
isReaderPageMediaActivationBlocked = false
|
||||
pendingProgressionRequest = false
|
||||
didRenderCurrentRequest = false
|
||||
teardownWebView()
|
||||
@ -207,6 +230,15 @@ public final class RDEPUBWebView: UIView {
|
||||
|
||||
let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? ""
|
||||
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(
|
||||
debugScope,
|
||||
message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -52,7 +52,7 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView, RDEPUBReaderBottom
|
||||
|
||||
configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录")
|
||||
configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
|
||||
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "批注")
|
||||
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "标注")
|
||||
configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
|
||||
configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
|
||||
chapterButton.accessibilityIdentifier = "epub.reader.toc"
|
||||
|
||||
@ -130,10 +130,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||
sourceRect: CGRect
|
||||
didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
|
||||
highlight: RDEPUBHighlight
|
||||
) {
|
||||
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
|
||||
runtime.handleHighlightMenuAction(action, highlight: highlight)
|
||||
}
|
||||
|
||||
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
|
||||
|
||||
@ -5,14 +5,16 @@ extension RDEPUBReaderController {
|
||||
|
||||
func applyReaderViewConfiguration() {
|
||||
let resolvedDirection = resolvedPageDirection()
|
||||
let allowsLandscapeDualPage = configuration.landscapeDualPageEnabled
|
||||
&& publication?.requiresSinglePagePresentation != true
|
||||
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|
||||
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
|
||||
|| readerView.landscapeDualPageEnabled != allowsLandscapeDualPage
|
||||
|| readerView.pageDirection != resolvedDirection
|
||||
let preservedLocation = presentationDidChange
|
||||
? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
|
||||
: nil
|
||||
view.backgroundColor = configuration.theme.contentBackgroundColor
|
||||
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
|
||||
readerView.landscapeDualPageEnabled = allowsLandscapeDualPage
|
||||
readerView.pageDirection = resolvedDirection
|
||||
updateReaderChrome()
|
||||
if presentationDidChange {
|
||||
|
||||
@ -374,14 +374,11 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
readerView.addSubview(searchBarView)
|
||||
readerView.searchBarView = searchBarView
|
||||
let bottomAnchor = bottomToolView.superview == nil
|
||||
? readerView.bottomAnchor
|
||||
: bottomToolView.topAnchor
|
||||
NSLayoutConstraint.activate([
|
||||
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
||||
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
||||
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
||||
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
searchBarView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor)
|
||||
])
|
||||
|
||||
searchBarView.alpha = 0
|
||||
|
||||
@ -14,14 +14,14 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
|
||||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||||
|
||||
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
|
||||
private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"])
|
||||
|
||||
private var filteredHighlights: [RDEPUBHighlight] {
|
||||
switch filterControl.selectedSegmentIndex {
|
||||
case 1:
|
||||
return highlights.filter(\.hasNote)
|
||||
case 2:
|
||||
return highlights.filter { $0.style == .highlight }
|
||||
return highlights.filter { !$0.hasNote }
|
||||
default:
|
||||
return highlights
|
||||
}
|
||||
@ -48,7 +48,11 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
view.accessibilityIdentifier = "epub.reader.highlights.panel"
|
||||
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
|
||||
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()
|
||||
applyTheme()
|
||||
updateEmptyState()
|
||||
@ -63,23 +67,42 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||
let highlight = filteredHighlights[indexPath.row]
|
||||
|
||||
cell.backgroundColor = theme.contentBackgroundColor
|
||||
cell.textLabel?.textColor = theme.contentTextColor
|
||||
cell.backgroundColor = .clear
|
||||
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?.numberOfLines = 2
|
||||
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?.numberOfLines = 3
|
||||
cell.detailTextLabel?.text = detailText(for: highlight)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.accessoryType = .none
|
||||
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) {
|
||||
guard !tableView.isEditing else { return }
|
||||
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() {
|
||||
@ -90,10 +113,10 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
}
|
||||
|
||||
private func applyTheme() {
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
tableView.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
||||
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||
navigationController?.navigationBar.barTintColor = tableView.backgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)]
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
|
||||
}
|
||||
|
||||
@ -120,7 +143,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
private func emptyStateText() -> String {
|
||||
switch filterControl.selectedSegmentIndex {
|
||||
case 1:
|
||||
return "暂无批注"
|
||||
return "暂无注释"
|
||||
case 2:
|
||||
return "暂无划线"
|
||||
default:
|
||||
@ -142,7 +165,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
private func titleText(for highlight: RDEPUBHighlight) -> String {
|
||||
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if highlight.hasNote {
|
||||
return "批注: \(text)"
|
||||
return "注释:\(text)"
|
||||
}
|
||||
return text
|
||||
}
|
||||
@ -150,58 +173,12 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
return highlight.hasNote ? "高亮批注" : "高亮"
|
||||
return highlight.hasNote ? "划线注释" : "划线"
|
||||
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) {
|
||||
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
|
||||
highlights.remove(at: index)
|
||||
@ -248,7 +225,10 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
|
||||
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
||||
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()
|
||||
updateEmptyState()
|
||||
}
|
||||
@ -262,30 +242,40 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||
let bookmark = bookmarks[indexPath.row]
|
||||
|
||||
cell.backgroundColor = theme.contentBackgroundColor
|
||||
cell.textLabel?.textColor = theme.contentTextColor
|
||||
cell.backgroundColor = .clear
|
||||
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?.numberOfLines = 2
|
||||
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?.numberOfLines = 3
|
||||
cell.detailTextLabel?.text = detailText(for: bookmark)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.accessoryType = .none
|
||||
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) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath)
|
||||
onSelectBookmark?(bookmarks[indexPath.row])
|
||||
}
|
||||
|
||||
private func applyTheme() {
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
tableView.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
||||
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||
navigationController?.navigationBar.barTintColor = tableView.backgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)]
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
||||
}
|
||||
|
||||
@ -322,25 +312,6 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
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) {
|
||||
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
|
||||
bookmarks.remove(at: index)
|
||||
|
||||
@ -119,21 +119,13 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
super.apply(theme: theme)
|
||||
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
|
||||
|
||||
let overlayColor = isDarkBackground
|
||||
? UIColor(white: 0.12, alpha: 0.92)
|
||||
: UIColor(white: 0.08, alpha: 0.82)
|
||||
let panelColor = isDarkBackground
|
||||
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
|
||||
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
|
||||
let rowColor = isDarkBackground
|
||||
? 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)
|
||||
let overlayColor = UIColor(white: 0.05, alpha: 0.32)
|
||||
let panelColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
||||
let rowColor = UIColor.white.withAlphaComponent(0.88)
|
||||
let cardColor = UIColor.white.withAlphaComponent(0.78)
|
||||
let activeCardColor = UIColor(red: 0.90, green: 0.94, blue: 1.0, alpha: 1)
|
||||
let textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
||||
let secondaryTextColor = UIColor(red: 0.38, green: 0.40, blue: 0.44, alpha: 1)
|
||||
|
||||
backgroundColor = .clear
|
||||
backgroundButton.backgroundColor = overlayColor
|
||||
@ -141,7 +133,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
|
||||
searchRowView.backgroundColor = rowColor
|
||||
searchFieldContainer.backgroundColor = .clear
|
||||
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
|
||||
searchFieldDivider.backgroundColor = UIColor.black.withAlphaComponent(0.10)
|
||||
searchIcon.tintColor = secondaryTextColor
|
||||
cancelButton.tintColor = textColor
|
||||
cancelButton.setTitleColor(textColor, for: .normal)
|
||||
@ -165,8 +157,8 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
|
||||
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
|
||||
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
|
||||
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
|
||||
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
||||
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||
|
||||
tableView.reloadData()
|
||||
}
|
||||
@ -274,10 +266,10 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
searchIcon.contentMode = .scaleAspectFit
|
||||
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
||||
|
||||
panelView.layer.cornerRadius = 28
|
||||
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
panelView.layer.cornerRadius = 0
|
||||
panelView.clipsToBounds = true
|
||||
|
||||
grabberView.isHidden = true
|
||||
grabberView.layer.cornerRadius = 3
|
||||
searchRowView.layer.cornerRadius = 22
|
||||
searchFieldContainer.layer.cornerRadius = 22
|
||||
@ -317,7 +309,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
|
||||
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
|
||||
panelView.topAnchor.constraint(equalTo: topAnchor),
|
||||
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
|
||||
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.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),
|
||||
|
||||
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
|
||||
@ -480,7 +472,7 @@ extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
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.numberOfLines = 2
|
||||
container.addSubview(label)
|
||||
|
||||
@ -339,6 +339,7 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
let state = [
|
||||
"reader=opened",
|
||||
"page=\(page)",
|
||||
"pagesPerScreen=\(readerController?.readerView.pagesPerScreen ?? 1)",
|
||||
"display=\(display)",
|
||||
"toolbar=\(toolbar)",
|
||||
"highlights=\(highlights)",
|
||||
|
||||
@ -53,7 +53,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
addAnnotation(from: selection, style: .highlight, color: color, note: note)
|
||||
addAnnotation(from: selection, style: .underline, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@ -61,7 +61,8 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
style: RDEPUBHighlightStyle,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
note: String? = nil,
|
||||
isAnnotationOnly: Bool = false
|
||||
) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
let sourceSelection = selection ?? controller.currentSelection
|
||||
@ -71,22 +72,35 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return nil
|
||||
}
|
||||
|
||||
let newHighlight = RDEPUBHighlight(
|
||||
var newHighlight = RDEPUBHighlight(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
location: scopedSelection.location,
|
||||
text: scopedSelection.text,
|
||||
rangeInfo: scopedSelection.rangeInfo,
|
||||
style: style,
|
||||
style: .underline,
|
||||
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
|
||||
highlight.location.href == newHighlight.location.href &&
|
||||
highlight.location.fragment == newHighlight.location.fragment &&
|
||||
highlight.text == newHighlight.text &&
|
||||
highlight.rangeInfo == newHighlight.rangeInfo &&
|
||||
highlight.style == newHighlight.style
|
||||
highlight.style == newHighlight.style &&
|
||||
highlight.isAnnotationOnly == newHighlight.isAnnotationOnly
|
||||
}
|
||||
guard !isDuplicate else {
|
||||
return nil
|
||||
@ -219,25 +233,29 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
presentAnnotationActionSheet(for: currentSelection)
|
||||
}
|
||||
|
||||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
||||
guard let controller else { return }
|
||||
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in
|
||||
_ = self?.removeHighlight(id: highlight.id)
|
||||
})
|
||||
if highlight.hasNote {
|
||||
alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in
|
||||
_ = self?.updateHighlightNote(id: highlight.id, note: nil)
|
||||
})
|
||||
func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
|
||||
switch action {
|
||||
case .copy:
|
||||
UIPasteboard.general.string = highlight.text
|
||||
case .createUnderline:
|
||||
guard highlight.isAnnotationOnly,
|
||||
let controller,
|
||||
let index = controller.activeHighlights.firstIndex(where: { $0.id == highlight.id }) else {
|
||||
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?) {
|
||||
@ -247,7 +265,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
UIPasteboard.general.string = selection.text
|
||||
updateCurrentSelection(nil)
|
||||
case .highlight:
|
||||
createAnnotation(from: selection, style: .highlight)
|
||||
createAnnotation(from: selection, style: .underline)
|
||||
case .annotate:
|
||||
presentAnnotationNoteEditor(for: selection)
|
||||
}
|
||||
@ -365,13 +383,65 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
location: normalizedLocation,
|
||||
text: highlight.text,
|
||||
rangeInfo: highlight.rangeInfo,
|
||||
style: highlight.style,
|
||||
style: .underline,
|
||||
color: highlight.color,
|
||||
note: highlight.note,
|
||||
isAnnotationOnly: highlight.isAnnotationOnly,
|
||||
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
|
||||
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
|
||||
guard let controller else { return false }
|
||||
@ -410,13 +480,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
|
||||
guard let controller else { return }
|
||||
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
|
||||
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)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
@ -429,25 +496,48 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
controller.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
|
||||
_ = addAnnotation(from: selection, style: style, note: note)
|
||||
private func createAnnotation(
|
||||
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) {
|
||||
guard let controller else { return }
|
||||
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert)
|
||||
alert.addTextField { textField in
|
||||
textField.placeholder = "输入批注内容"
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
||||
presentNoteEditor(quote: selection.text, initialNote: nil) { [weak self] note in
|
||||
guard note != nil else { return }
|
||||
self?.createAnnotation(
|
||||
from: selection,
|
||||
style: .highlight,
|
||||
note: alert?.textFields?.first?.text
|
||||
style: .underline,
|
||||
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? {
|
||||
|
||||
@ -4,6 +4,8 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private var settingsTransitionDelegate: RDEPUBReaderSettingsTransitionDelegate?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
@ -122,9 +124,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
|
||||
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
|
||||
}
|
||||
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
|
||||
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
|
||||
}
|
||||
settingsController.onDisplayTypeChange = { [weak controller] displayType in
|
||||
controller?.updateConfiguration { $0.displayType = displayType }
|
||||
}
|
||||
@ -137,8 +136,13 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: settingsController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
navigationController.presentationController?.delegate = self
|
||||
let transitionDelegate = RDEPUBReaderSettingsTransitionDelegate()
|
||||
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)
|
||||
}
|
||||
|
||||
@ -233,3 +237,77 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,7 +163,11 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -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 {
|
||||
guard publication.spine.indices.contains(index) else { return false }
|
||||
let item = publication.spine[index]
|
||||
|
||||
@ -256,8 +256,8 @@ final class RDEPUBReaderRuntime {
|
||||
annotationCoordinator.presentAnnotationCreation()
|
||||
}
|
||||
|
||||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
||||
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
|
||||
func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
|
||||
annotationCoordinator.handleHighlightMenuAction(action, highlight: highlight)
|
||||
}
|
||||
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
|
||||
@ -1,30 +1,19 @@
|
||||
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 {
|
||||
|
||||
var onBrightnessChange: ((CGFloat) -> Void)?
|
||||
|
||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||
|
||||
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
|
||||
|
||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||
|
||||
var onColumnCountChange: ((Int) -> Void)?
|
||||
|
||||
var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
|
||||
|
||||
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
||||
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
private enum ThemePreset: Int, CaseIterable {
|
||||
case light
|
||||
case yellow
|
||||
case green
|
||||
case pink
|
||||
case blue
|
||||
case dark
|
||||
case light, yellow, green, pink, blue, dark
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@ -49,50 +38,24 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
|
||||
private let contentStack: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 20
|
||||
return stackView
|
||||
}()
|
||||
|
||||
private let handleView = UIView()
|
||||
private let closeButton = UIButton(type: .system)
|
||||
private let brightnessSlider = UISlider()
|
||||
|
||||
private let fontValueLabel = UILabel()
|
||||
|
||||
private let decreaseFontButton = UIButton(type: .system)
|
||||
|
||||
private let increaseFontButton = UIButton(type: .system)
|
||||
|
||||
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 let fontSizeSlider = UISlider()
|
||||
private let fontSizeLabel = UILabel()
|
||||
private let fontChoiceButton = UIButton(type: .system)
|
||||
private let lineHeightButton = UIButton(type: .system)
|
||||
private let displayTypeButton = UIButton(type: .system)
|
||||
private let themeStack = UIStackView()
|
||||
private var themeButtons: [UIButton] = []
|
||||
|
||||
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
||||
|
||||
private var currentConfiguration: RDEPUBReaderConfiguration
|
||||
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
||||
private var hasNotifiedDismissal = false
|
||||
|
||||
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
||||
self.currentConfiguration = configuration
|
||||
currentConfiguration = configuration
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
brightnessSlider.value = Float(brightness)
|
||||
title = "样式设置"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
@ -101,262 +64,266 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupNavigationItems()
|
||||
setupViews()
|
||||
syncControls()
|
||||
applyTheme(currentConfiguration.theme)
|
||||
}
|
||||
|
||||
private func setupNavigationItems() {
|
||||
let doneItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
|
||||
doneItem.accessibilityIdentifier = "epub.reader.settings.done"
|
||||
navigationItem.rightBarButtonItem = doneItem
|
||||
private func setupViews() {
|
||||
view.accessibilityIdentifier = "epub.reader.settings.panel"
|
||||
view.layer.cornerRadius = 24
|
||||
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() {
|
||||
view.addSubview(scrollView)
|
||||
scrollView.accessibilityIdentifier = "epub.reader.settings.scroll"
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.addSubview(contentStack)
|
||||
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)
|
||||
])
|
||||
handleView.layer.cornerRadius = 3
|
||||
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
closeButton.accessibilityIdentifier = "epub.reader.settings.done"
|
||||
closeButton.accessibilityLabel = "完成"
|
||||
closeButton.addTarget(self, action: #selector(doneAction), for: .touchUpInside)
|
||||
|
||||
brightnessSlider.minimumValue = 0
|
||||
brightnessSlider.maximumValue = 1
|
||||
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
|
||||
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
|
||||
|
||||
fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold)
|
||||
fontValueLabel.textAlignment = .center
|
||||
fontValueLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
|
||||
fontValueLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
fontSizeSlider.minimumValue = 12
|
||||
fontSizeSlider.maximumValue = 36
|
||||
fontSizeSlider.accessibilityIdentifier = "epub.reader.settings.font.size"
|
||||
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-")
|
||||
configureFontButton(increaseFontButton, title: "A+")
|
||||
decreaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.decrease"
|
||||
increaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.increase"
|
||||
decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside)
|
||||
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
|
||||
|
||||
lineHeightControl.accessibilityIdentifier = "epub.reader.settings.lineHeight"
|
||||
fontChoiceControl.accessibilityIdentifier = "epub.reader.settings.font.choice"
|
||||
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)
|
||||
configurePill(fontChoiceButton, title: "字体")
|
||||
configurePill(lineHeightButton, title: "行距")
|
||||
configurePill(displayTypeButton, title: "翻页方式")
|
||||
fontChoiceButton.accessibilityIdentifier = "epub.reader.settings.font.choice"
|
||||
lineHeightButton.accessibilityIdentifier = "epub.reader.settings.lineHeight"
|
||||
displayTypeButton.accessibilityIdentifier = "epub.reader.settings.displayType"
|
||||
fontChoiceButton.addTarget(self, action: #selector(fontChoiceAction), for: .touchUpInside)
|
||||
lineHeightButton.addTarget(self, action: #selector(lineHeightAction), for: .touchUpInside)
|
||||
displayTypeButton.addTarget(self, action: #selector(displayTypeAction), for: .touchUpInside)
|
||||
|
||||
themeStack.axis = .horizontal
|
||||
themeStack.distribution = .fillEqually
|
||||
themeStack.spacing = 14
|
||||
ThemePreset.allCases.forEach { preset in
|
||||
let button = UIButton(type: .system)
|
||||
button.tag = preset.rawValue
|
||||
button.layer.cornerRadius = 18
|
||||
button.layer.borderWidth = 1.5
|
||||
button.backgroundColor = preset.theme.contentBackgroundColor
|
||||
button.layer.cornerRadius = 15
|
||||
button.layer.borderWidth = 1
|
||||
button.accessibilityLabel = preset.title
|
||||
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
|
||||
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
|
||||
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([
|
||||
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))
|
||||
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
|
||||
contentStack.addArrangedSubview(makeSection(title: "字体", content: fontChoiceControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
|
||||
private func makeLabel(_ text: String, font: CGFloat = 13) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = UIFont.systemFont(ofSize: font, weight: font > 16 ? .regular : .semibold)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeSection(title: String, content: UIView) -> UIView {
|
||||
let container = UIStackView()
|
||||
container.axis = .vertical
|
||||
container.spacing = 10
|
||||
|
||||
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 makeDivider() -> UIView {
|
||||
let divider = UIView()
|
||||
divider.translatesAutoresizingMaskIntoConstraints = false
|
||||
divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
|
||||
return divider
|
||||
}
|
||||
|
||||
private func makeFontSizeRow() -> UIView {
|
||||
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) {
|
||||
private func configurePill(_ button: UIButton, title: String) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
|
||||
button.layer.cornerRadius = 18
|
||||
button.layer.borderWidth = 1
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
||||
button.layer.cornerRadius = 13
|
||||
}
|
||||
|
||||
private func syncControls() {
|
||||
fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
|
||||
fontChoiceControl.selectedSegmentIndex = RDEPUBReaderFontChoice.allCases.firstIndex(of: currentConfiguration.fontChoice) ?? 0
|
||||
|
||||
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
|
||||
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
|
||||
fontSizeSlider.value = Float(currentConfiguration.fontSize)
|
||||
fontSizeLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
|
||||
updateSelectorTitles()
|
||||
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light)
|
||||
}
|
||||
|
||||
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
|
||||
updateThemeSelection(selectedPreset)
|
||||
updateControlAccessibilityValues()
|
||||
private func updateSelectorTitles() {
|
||||
fontChoiceButton.setTitle("\(currentConfiguration.fontChoice.displayName) ›", for: .normal)
|
||||
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) {
|
||||
view.backgroundColor = theme.contentBackgroundColor
|
||||
scrollView.backgroundColor = theme.contentBackgroundColor
|
||||
contentStack.arrangedSubviews
|
||||
.compactMap { $0 as? UIStackView }
|
||||
.flatMap { $0.arrangedSubviews }
|
||||
.compactMap { $0 as? UILabel }
|
||||
.forEach { $0.textColor = theme.contentTextColor }
|
||||
|
||||
fontValueLabel.textColor = theme.contentTextColor
|
||||
[decreaseFontButton, increaseFontButton].forEach { button in
|
||||
button.setTitleColor(theme.toolControlTextColor, for: .normal)
|
||||
button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor
|
||||
button.backgroundColor = theme.toolBackgroundColor
|
||||
view.backgroundColor = theme.toolBackgroundColor.withAlphaComponent(0.98)
|
||||
let textColor = theme.toolControlTextColor
|
||||
handleView.backgroundColor = textColor.withAlphaComponent(0.45)
|
||||
closeButton.tintColor = textColor
|
||||
view.subviews.compactMap { $0 as? UILabel }.forEach { $0.textColor = textColor }
|
||||
view.subviews.filter { $0 !== handleView && $0 !== closeButton && !($0 is UISlider) && !($0 is UIStackView) && !($0 is UILabel) }
|
||||
.forEach { $0.backgroundColor = textColor.withAlphaComponent(0.12) }
|
||||
[fontChoiceButton, lineHeightButton, displayTypeButton].forEach {
|
||||
$0.setTitleColor(textColor, for: .normal)
|
||||
$0.backgroundColor = textColor.withAlphaComponent(0.10)
|
||||
}
|
||||
|
||||
[fontChoiceControl, lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
|
||||
control.backgroundColor = theme.toolBackgroundColor
|
||||
if #available(iOS 13.0, *) {
|
||||
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]
|
||||
brightnessSlider.minimumTrackTintColor = .systemBlue
|
||||
fontSizeSlider.minimumTrackTintColor = .systemBlue
|
||||
brightnessSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
|
||||
fontSizeSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
|
||||
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
|
||||
}
|
||||
|
||||
private func updateThemeSelection(_ preset: ThemePreset) {
|
||||
themeButtons.forEach { button in
|
||||
let isSelected = button.tag == preset.rawValue
|
||||
button.layer.borderWidth = isSelected ? 2 : 1
|
||||
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
|
||||
button.accessibilityValue = isSelected ? "selected" : "unselected"
|
||||
let selected = button.tag == preset.rawValue
|
||||
button.layer.borderWidth = selected ? 2.5 : 1
|
||||
button.layer.borderColor = selected ? UIColor.systemBlue.cgColor : UIColor.white.withAlphaComponent(0.28).cgColor
|
||||
button.accessibilityValue = selected ? "selected" : "unselected"
|
||||
}
|
||||
}
|
||||
|
||||
private func updateControlAccessibilityValues() {
|
||||
fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex)
|
||||
lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex)
|
||||
columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex)
|
||||
displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
|
||||
func notifyDismissalIfNeeded() {
|
||||
guard !hasNotifiedDismissal else { return }
|
||||
hasNotifiedDismissal = true
|
||||
onDismiss?()
|
||||
}
|
||||
|
||||
@objc private func doneAction() {
|
||||
dismiss(animated: true) { [weak self] in
|
||||
self?.onDismiss?()
|
||||
notifyDismissalIfNeeded()
|
||||
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) {
|
||||
onBrightnessChange?(CGFloat(slider.value))
|
||||
@objc private func fontChoiceAction() {
|
||||
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() {
|
||||
let nextValue = max(12, currentConfiguration.fontSize - 1)
|
||||
guard nextValue != currentConfiguration.fontSize else { return }
|
||||
currentConfiguration.fontSize = nextValue
|
||||
fontValueLabel.text = String(Int(nextValue.rounded()))
|
||||
onFontSizeChange?(nextValue)
|
||||
@objc private func lineHeightAction() {
|
||||
let alert = UIAlertController(title: "行距", message: nil, preferredStyle: .actionSheet)
|
||||
zip(["紧凑", "标准", "宽松"], lineHeightValues).forEach { title, value in
|
||||
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.currentConfiguration.lineHeightMultiple = value
|
||||
self.updateSelectorTitles()
|
||||
self.onLineHeightChange?(value)
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func increaseFontAction() {
|
||||
let nextValue = min(36, currentConfiguration.fontSize + 1)
|
||||
guard nextValue != currentConfiguration.fontSize else { return }
|
||||
currentConfiguration.fontSize = nextValue
|
||||
fontValueLabel.text = String(Int(nextValue.rounded()))
|
||||
onFontSizeChange?(nextValue)
|
||||
@objc private func displayTypeAction() {
|
||||
let alert = UIAlertController(title: "翻页方式", message: nil, preferredStyle: .actionSheet)
|
||||
[("仿真", RDEpubReaderView.DisplayType.pageCurl), ("横滑", .horizontalScroll), ("竖滑", .verticalScroll)].forEach { title, type in
|
||||
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.currentConfiguration.displayType = type
|
||||
self.updateSelectorTitles()
|
||||
self.onDisplayTypeChange?(type)
|
||||
})
|
||||
}
|
||||
|
||||
@objc private func fontChoiceChanged(_ control: UISegmentedControl) {
|
||||
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)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func themeButtonAction(_ sender: UIButton) {
|
||||
|
||||
@ -28,11 +28,19 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||
sourceRect: CGRect
|
||||
didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
|
||||
highlight: RDEPUBHighlight
|
||||
)
|
||||
}
|
||||
|
||||
enum RDEPUBExistingHighlightMenuAction {
|
||||
case copy
|
||||
case createUnderline
|
||||
case deleteUnderline
|
||||
case annotate
|
||||
case deleteAnnotation
|
||||
}
|
||||
|
||||
extension RDEPUBTextContentViewDelegate {
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
@ -60,6 +68,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
|
||||
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+).
|
||||
// Stored as Any? to avoid @available on stored property restriction.
|
||||
private var _editMenuInteraction: Any?
|
||||
@ -347,23 +364,52 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
}
|
||||
|
||||
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_annotate(_:))
|
||||
}
|
||||
|
||||
@objc func rd_copy(_ sender: Any?) {
|
||||
if let highlight = menuHighlight {
|
||||
performHighlightMenuAction(.copy, highlight: highlight)
|
||||
return
|
||||
}
|
||||
performSelectionAction(.copy)
|
||||
}
|
||||
|
||||
@objc func rd_highlight(_ sender: Any?) {
|
||||
if let highlight = menuAnnotationHighlight {
|
||||
performHighlightMenuAction(.createUnderline, highlight: highlight)
|
||||
return
|
||||
}
|
||||
performSelectionAction(.highlight)
|
||||
}
|
||||
|
||||
@objc func rd_annotate(_ sender: Any?) {
|
||||
if let highlight = menuHighlight {
|
||||
performHighlightMenuAction(.annotate, highlight: highlight)
|
||||
return
|
||||
}
|
||||
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() {
|
||||
super.layoutSubviews()
|
||||
|
||||
@ -855,51 +901,85 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
)
|
||||
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 {
|
||||
RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
|
||||
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
||||
return
|
||||
}
|
||||
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)")
|
||||
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
|
||||
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightIds=\(highlights.map(\.id).joined(separator: ","))")
|
||||
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() {
|
||||
menuHighlight = nil
|
||||
menuUnderlineHighlight = nil
|
||||
menuAnnotationHighlight = nil
|
||||
guard currentSelection != nil,
|
||||
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
||||
!targetRect.isEmpty else {
|
||||
return
|
||||
}
|
||||
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()
|
||||
let menuController = UIMenuController.shared
|
||||
menuController.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
UIMenuItem(title: "复制", action: #selector(rd_copy(_:))),
|
||||
UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))),
|
||||
UIMenuItem(title: "注释", action: #selector(rd_annotate(_:)))
|
||||
]
|
||||
menuController.setTargetRect(resolvedTargetRect, in: self)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func hideSelectionMenu() {
|
||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
||||
interaction.dismissMenu()
|
||||
} else {
|
||||
menuHighlight = nil
|
||||
menuUnderlineHighlight = nil
|
||||
menuAnnotationHighlight = nil
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelectionLoupe(for point: CGPoint) {
|
||||
#if canImport(DTCoreText)
|
||||
@ -937,7 +1017,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
}
|
||||
|
||||
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
|
||||
guard highlight.location.href == page.href,
|
||||
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
|
||||
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
|
||||
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
|
||||
if lhs.hasNote != rhs.hasNote {
|
||||
return lhs.hasNote && !rhs.hasNote
|
||||
}
|
||||
if lhsRange != rhsRange {
|
||||
// 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
|
||||
}.first
|
||||
}
|
||||
return lhs.createdAt > rhs.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
|
||||
@ -1133,9 +1219,9 @@ extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
||||
suggestedActions: [UIMenuElement]
|
||||
) -> UIMenu? {
|
||||
UIMenu(children: [
|
||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
UICommand(title: "复制", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "划线", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "注释", action: #selector(rd_annotate(_:)))
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@ -127,14 +127,19 @@ final class RDEpubReaderPreloadController {
|
||||
return preloaded
|
||||
}
|
||||
|
||||
func updatePageVisibility(visiblePageNumbers: Set<Int>) {
|
||||
var notifiedViews = Set<ObjectIdentifier>()
|
||||
func updatePageVisibility(visibleViewIdentifiers: Set<ObjectIdentifier>) {
|
||||
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)
|
||||
guard notifiedViews.insert(identifier).inserted else { continue }
|
||||
guard !visibleViewIdentifiers.contains(identifier),
|
||||
notifiedViews.insert(identifier).inserted else { continue }
|
||||
(view as? RDEpubReaderPageVisibilityHandling)?
|
||||
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageNumber))
|
||||
.readerPageVisibilityDidChange(isVisible: false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,7 +152,7 @@ final class RDEpubReaderPreloadController {
|
||||
) {
|
||||
refreshCacheSignatureIfNeeded(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)
|
||||
guard !targets.isEmpty else { return }
|
||||
|
||||
@ -194,6 +199,8 @@ final class RDEpubReaderPreloadController {
|
||||
"cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))"
|
||||
)
|
||||
}
|
||||
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||
.readerPageVisibilityDidChange(isVisible: false)
|
||||
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
||||
if shouldCacheContentView {
|
||||
preloadedPageViews[targetPage] = contentView
|
||||
@ -309,10 +316,6 @@ final class RDEpubReaderPreloadController {
|
||||
).left
|
||||
}
|
||||
|
||||
private func visiblePageNumbers(for anchorPage: Int, environment: Environment) -> Set<Int> {
|
||||
spreadPageNumbers(startingAt: anchorPage, environment: environment)
|
||||
}
|
||||
|
||||
private func detachedReusablePageView(for pageNum: Int) -> UIView? {
|
||||
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
|
||||
preloaded.removeFromSuperview()
|
||||
@ -351,6 +354,8 @@ final class RDEpubReaderPreloadController {
|
||||
// 的缓存重新呈现,提前拆除会让翻页露出空白页。这类视图交由持有方
|
||||
// 释放后经 deinit 自然回收(弱代理已保证无保留环)。
|
||||
let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView
|
||||
(view as? RDEpubReaderPageVisibilityHandling)?
|
||||
.readerPageVisibilityDidChange(isVisible: false)
|
||||
view.removeFromSuperview()
|
||||
if !isHeldOutsidePreloadHost {
|
||||
(view as? RDEpubReaderPageResourceReleasing)?.releaseResources()
|
||||
|
||||
@ -11,8 +11,11 @@ extension RDEpubReaderView: UICollectionViewDataSource, RDEpubReaderFlowLayoutDe
|
||||
let reusableView = cell.containerView ?? preloadedView
|
||||
let contentView = contentViewForPage(indexPath.row, reusableView: reusableView)
|
||||
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(indexPath.row))
|
||||
.readerPageVisibilityDidChange(isVisible: false)
|
||||
cell.containerView = contentView
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updatePageContentVisibility()
|
||||
}
|
||||
|
||||
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
|
||||
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||
|
||||
@ -96,7 +96,28 @@ extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewController
|
||||
}
|
||||
|
||||
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 {
|
||||
let pn = firstVC.pageNum
|
||||
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
|
||||
finishPageCurlTransition()
|
||||
}
|
||||
|
||||
@ -527,10 +527,13 @@ public class RDEpubReaderView: UIView {
|
||||
let pageView = preloadController.pageViewForDisplay(
|
||||
pageNum: pageNum,
|
||||
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)?
|
||||
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(pageNum))
|
||||
.readerPageVisibilityDidChange(isVisible: false)
|
||||
return pageView
|
||||
}
|
||||
|
||||
@ -551,10 +554,6 @@ public class RDEpubReaderView: UIView {
|
||||
return contentViewForPage(pageNum, reusableView: reusableView)
|
||||
}
|
||||
|
||||
private func pageContentViewForDisplay(pageNum: Int, reusableView: UIView?) -> UIView? {
|
||||
return contentViewForPage(pageNum, reusableView: reusableView)
|
||||
}
|
||||
|
||||
func isReaderPageVisible(_ pageNum: Int) -> Bool {
|
||||
guard currentPage >= 0 else { return pageNum == 0 }
|
||||
if landscapeDualPageEnabled && isLandscape {
|
||||
@ -564,7 +563,7 @@ public class RDEpubReaderView: UIView {
|
||||
return pageNum == currentPage
|
||||
}
|
||||
|
||||
private func updatePageContentVisibility() {
|
||||
func updatePageContentVisibility() {
|
||||
var visiblePageNumbers = Set<Int>()
|
||||
if currentPage >= 0 {
|
||||
visiblePageNumbers.insert(currentPage)
|
||||
@ -574,20 +573,57 @@ public class RDEpubReaderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
preloadController.updatePageVisibility(visiblePageNumbers: visiblePageNumbers)
|
||||
|
||||
for viewController in pageViewController.viewControllers ?? [] {
|
||||
guard let pageController = viewController as? RDEpubReaderPageChildViewController,
|
||||
let contentView = pageController.contentView else { continue }
|
||||
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageController.pageNum))
|
||||
let pageControllers: [RDEpubReaderPageChildViewController]
|
||||
let visibleCellContents: [(pageNumber: Int, contentView: UIView)]
|
||||
if currentDisplayType == .pageCurl {
|
||||
pageControllers = (pageViewController.viewControllers ?? []).compactMap {
|
||||
$0 as? RDEpubReaderPageChildViewController
|
||||
}
|
||||
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 {
|
||||
guard let cell = collectionView.cellForItem(at: indexPath) as? RDEpubReaderContentCell,
|
||||
let contentView = cell.containerView else { continue }
|
||||
for pageController in pageControllers where !visiblePageNumbers.contains(pageController.pageNum) {
|
||||
guard let contentView = pageController.contentView else { continue }
|
||||
(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() {
|
||||
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 {
|
||||
pageViewController.willMove(toParent: nil)
|
||||
}
|
||||
@ -882,6 +923,17 @@ public class RDEpubReaderView: UIView {
|
||||
}
|
||||
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
||||
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 {
|
||||
let pair = dualPagePair(for: safePageNum)
|
||||
let leftContent = pageViewForDisplay(pageNum: pair.left)
|
||||
@ -903,7 +955,11 @@ public class RDEpubReaderView: UIView {
|
||||
self.finishPageCurlTransition()
|
||||
}
|
||||
}
|
||||
let pageChanged = currentPage != pair.left
|
||||
currentPage = pair.left
|
||||
if !pageChanged {
|
||||
updatePageContentVisibility()
|
||||
}
|
||||
primePageCache(around: pair.left, preferredForward: predictedPageDirection)
|
||||
} else {
|
||||
let contentView = pageViewForDisplay(pageNum: safePageNum)
|
||||
@ -913,7 +969,11 @@ public class RDEpubReaderView: UIView {
|
||||
guard let self else { return }
|
||||
self.finishPageCurlTransition()
|
||||
}
|
||||
let pageChanged = currentPage != safePageNum
|
||||
currentPage = safePageNum
|
||||
if !pageChanged {
|
||||
updatePageContentVisibility()
|
||||
}
|
||||
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
||||
}
|
||||
default:
|
||||
@ -921,7 +981,11 @@ public class RDEpubReaderView: UIView {
|
||||
collectionView.layoutIfNeeded()
|
||||
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
||||
collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated)
|
||||
let pageChanged = currentPage != safePageNum
|
||||
currentPage = safePageNum
|
||||
if !pageChanged {
|
||||
updatePageContentVisibility()
|
||||
}
|
||||
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user