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 settingsFontValue = "epub.reader.settings.font.value"
|
||||||
static let settingsFontChoice = "epub.reader.settings.font.choice"
|
static let settingsFontChoice = "epub.reader.settings.font.choice"
|
||||||
static let settingsLineHeight = "epub.reader.settings.lineHeight"
|
static let settingsLineHeight = "epub.reader.settings.lineHeight"
|
||||||
static let settingsColumns = "epub.reader.settings.columns"
|
|
||||||
static let settingsDisplayType = "epub.reader.settings.displayType"
|
static let settingsDisplayType = "epub.reader.settings.displayType"
|
||||||
static let settingsDone = "epub.reader.settings.done"
|
static let settingsDone = "epub.reader.settings.done"
|
||||||
static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" }
|
static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" }
|
||||||
|
|||||||
@ -28,6 +28,7 @@ struct DemoReaderState {
|
|||||||
|
|
||||||
var isOpened: Bool { fields["reader"] == "opened" }
|
var isOpened: Bool { fields["reader"] == "opened" }
|
||||||
var page: Int? { fields["page"].flatMap(Int.init) }
|
var page: Int? { fields["page"].flatMap(Int.init) }
|
||||||
|
var pagesPerScreen: Int? { fields["pagesPerScreen"].flatMap(Int.init) }
|
||||||
var display: String? { fields["display"] }
|
var display: String? { fields["display"] }
|
||||||
var toolbar: String? { fields["toolbar"] }
|
var toolbar: String? { fields["toolbar"] }
|
||||||
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
|
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
|
||||||
|
|||||||
@ -23,6 +23,11 @@ final class FixedLayoutRotationTests: XCTestCase {
|
|||||||
for _ in 0..<3 {
|
for _ in 0..<3 {
|
||||||
XCUIDevice.shared.orientation = .landscapeLeft
|
XCUIDevice.shared.orientation = .landscapeLeft
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
||||||
|
let landscapeState = app.waitForDemoReaderState(
|
||||||
|
timeout: 5,
|
||||||
|
description: "固定版式横屏保持单页"
|
||||||
|
) { $0.pagesPerScreen == 1 }
|
||||||
|
XCTAssertEqual(landscapeState.pagesPerScreen, 1, "交互型固定版式书横屏不应启用双页")
|
||||||
XCUIDevice.shared.orientation = .portrait
|
XCUIDevice.shared.orientation = .portrait
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
||||||
}
|
}
|
||||||
@ -30,4 +35,29 @@ final class FixedLayoutRotationTests: XCTestCase {
|
|||||||
XCTAssertEqual(app.state, .runningForeground, "反复旋转后 app 已退出(疑似崩溃)")
|
XCTAssertEqual(app.state, .runningForeground, "反复旋转后 app 已退出(疑似崩溃)")
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFixedLayoutCachedPageCanReturnAfterPageTurn() {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "熊爷爷")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let content = app.otherElements[IDs.readerContent]
|
||||||
|
XCTAssertTrue(content.waitForExistence(timeout: 5), "固定版式阅读区域未出现")
|
||||||
|
let initialState = app.waitForDemoReaderState(timeout: 5, description: "固定版式初始页") {
|
||||||
|
$0.page != nil
|
||||||
|
}
|
||||||
|
let initialPage = initialState.page ?? 0
|
||||||
|
|
||||||
|
content.swipeLeft()
|
||||||
|
let nextState = app.waitForDemoReaderState(timeout: 8, description: "固定版式向后翻页") { state in
|
||||||
|
guard let page = state.page else { return false }
|
||||||
|
return page != initialPage
|
||||||
|
}
|
||||||
|
|
||||||
|
content.swipeRight()
|
||||||
|
let returnedState = app.waitForDemoReaderState(timeout: 8, description: "固定版式返回缓存页") {
|
||||||
|
$0.page == initialPage
|
||||||
|
}
|
||||||
|
XCTAssertNotEqual(nextState.page, returnedState.page, "固定版式返回缓存页后页码未恢复")
|
||||||
|
XCTAssertTrue(content.exists, "返回缓存页后阅读区域不应消失")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,9 +19,9 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["划线"]
|
||||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||||
else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() }
|
else if app.buttons["划线"].waitForExistence(timeout: 2) { app.buttons["划线"].tap() }
|
||||||
|
|
||||||
app.waitForDemoReaderState(timeout: 5, description: "highlights exists") { $0.highlights != nil }
|
app.waitForDemoReaderState(timeout: 5, description: "highlights exists") { $0.highlights != nil }
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["划线"]
|
||||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
@ -63,7 +63,7 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["划线"]
|
||||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
@ -102,7 +102,7 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["划线"]
|
||||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
@ -112,15 +112,14 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5))
|
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5))
|
||||||
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5))
|
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5))
|
||||||
|
|
||||||
highlightsTable.cells.element(boundBy: 0).tap()
|
let editButton = app.navigationBars.buttons["编辑"].firstMatch
|
||||||
|
XCTAssertTrue(editButton.waitForExistence(timeout: 3), "标注列表应提供编辑入口")
|
||||||
|
editButton.tap()
|
||||||
|
|
||||||
let deleteButton = app.buttons["删除标注"].firstMatch
|
let deleteControl = highlightsTable.buttons["删除"].firstMatch
|
||||||
let deleteHighlightButton = app.buttons["删除高亮"].firstMatch
|
XCTAssertTrue(deleteControl.waitForExistence(timeout: 3), "编辑状态应显示删除控件")
|
||||||
if deleteButton.waitForExistence(timeout: 3) {
|
deleteControl.tap()
|
||||||
deleteButton.tap()
|
app.buttons["删除"].lastMatch.tap()
|
||||||
} else if deleteHighlightButton.waitForExistence(timeout: 1) {
|
|
||||||
deleteHighlightButton.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -136,8 +136,8 @@ final class LocationPersistenceTests: XCTestCase {
|
|||||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
||||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
|
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
XCTAssertTrue(app.menuItems["高亮"].waitForExistence(timeout: 3))
|
XCTAssertTrue(app.menuItems["划线"].waitForExistence(timeout: 3))
|
||||||
app.menuItems["高亮"].tap()
|
app.menuItems["划线"].tap()
|
||||||
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
@ -167,7 +167,7 @@ final class LocationPersistenceTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let highlightItem = app.menuItems["高亮"]
|
let highlightItem = app.menuItems["划线"]
|
||||||
XCTAssertTrue(highlightItem.waitForExistence(timeout: 3), "应出现高亮菜单")
|
XCTAssertTrue(highlightItem.waitForExistence(timeout: 3), "应出现高亮菜单")
|
||||||
highlightItem.tap()
|
highlightItem.tap()
|
||||||
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||||
|
|||||||
@ -22,7 +22,7 @@ final class ReaderAnnotationTests: XCTestCase {
|
|||||||
|
|
||||||
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["划线"]
|
||||||
let highlightButton = app.buttons[IDs.readerSelectionHighlight]
|
let highlightButton = app.buttons[IDs.readerSelectionHighlight]
|
||||||
if highlightMenuItem.waitForExistence(timeout: 3) {
|
if highlightMenuItem.waitForExistence(timeout: 3) {
|
||||||
highlightMenuItem.tap()
|
highlightMenuItem.tap()
|
||||||
@ -65,7 +65,7 @@ final class ReaderAnnotationTests: XCTestCase {
|
|||||||
|
|
||||||
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
||||||
|
|
||||||
let annotateMenuItem = app.menuItems["批注"]
|
let annotateMenuItem = app.menuItems["注释"]
|
||||||
XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 3), "选中文本后未出现批注菜单")
|
XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 3), "选中文本后未出现批注菜单")
|
||||||
annotateMenuItem.tap()
|
annotateMenuItem.tap()
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
|||||||
Thread.sleep(forTimeInterval: 0.5)
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
// 点击批注菜单项
|
// 点击批注菜单项
|
||||||
let annotateItem = app.menuItems["批注"]
|
let annotateItem = app.menuItems["注释"]
|
||||||
guard annotateItem.waitForExistence(timeout: 3) else {
|
guard annotateItem.waitForExistence(timeout: 3) else {
|
||||||
throw XCTSkip("批注菜单项不存在")
|
throw XCTSkip("批注菜单项不存在")
|
||||||
}
|
}
|
||||||
@ -56,7 +56,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
|||||||
|
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
let annotateItem = app.menuItems["批注"]
|
let annotateItem = app.menuItems["注释"]
|
||||||
guard annotateItem.waitForExistence(timeout: 3) else {
|
guard annotateItem.waitForExistence(timeout: 3) else {
|
||||||
throw XCTSkip("批注菜单项不存在")
|
throw XCTSkip("批注菜单项不存在")
|
||||||
}
|
}
|
||||||
@ -101,7 +101,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
|||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
let highlightItem = app.menuItems["高亮"]
|
let highlightItem = app.menuItems["划线"]
|
||||||
guard highlightItem.waitForExistence(timeout: 3) else {
|
guard highlightItem.waitForExistence(timeout: 3) else {
|
||||||
throw XCTSkip("高亮菜单项不存在")
|
throw XCTSkip("高亮菜单项不存在")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ final class SelectionMenuTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
|
XCTAssertTrue(app.menuItems["复制"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSelectionMenuShowsAnnotateOption() throws {
|
func testSelectionMenuShowsAnnotateOption() throws {
|
||||||
@ -32,7 +32,7 @@ final class SelectionMenuTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
XCTAssertTrue(app.menuItems["批注"].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
|
XCTAssertTrue(app.menuItems["注释"].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTapBlankAreaClearsSelection() throws {
|
func testTapBlankAreaClearsSelection() throws {
|
||||||
@ -46,14 +46,14 @@ final class SelectionMenuTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应出现")
|
XCTAssertTrue(app.menuItems["复制"].waitForExistence(timeout: 3), "选区菜单应出现")
|
||||||
|
|
||||||
let contentArea = app.otherElements[IDs.readerContentView].firstMatch
|
let contentArea = app.otherElements[IDs.readerContentView].firstMatch
|
||||||
if contentArea.waitForExistence(timeout: 3) {
|
if contentArea.waitForExistence(timeout: 3) {
|
||||||
contentArea.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
|
contentArea.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
let menuGone = !app.menuItems["拷贝"].waitForExistence(timeout: 2)
|
let menuGone = !app.menuItems["复制"].waitForExistence(timeout: 2)
|
||||||
XCTAssertTrue(menuGone, "点击空白区域后选区菜单应消失")
|
XCTAssertTrue(menuGone, "点击空白区域后选区菜单应消失")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,7 +68,7 @@ final class SelectionMenuTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let copyMenuItem = app.menuItems["拷贝"]
|
let copyMenuItem = app.menuItems["复制"]
|
||||||
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
|
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
|
||||||
|
|
||||||
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
|
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
|
||||||
|
|||||||
@ -44,24 +44,6 @@ final class SettingsEffectTests: XCTestCase {
|
|||||||
XCTAssertTrue(contentView.waitForExistence(timeout: 5), "主题切换后内容区应存在")
|
XCTAssertTrue(contentView.waitForExistence(timeout: 5), "主题切换后内容区应存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testColumnCountChangeUpdatesLayout() throws {
|
|
||||||
app.launchAndOpenSampleBook()
|
|
||||||
app.waitForReader(timeout: 12)
|
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
|
||||||
app.buttons[IDs.readerSettings].tap()
|
|
||||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
|
||||||
app.scrollViews[IDs.settingsScroll].swipeUp()
|
|
||||||
|
|
||||||
let columnsControl = app.segmentedControls[IDs.settingsColumns]
|
|
||||||
if columnsControl.waitForExistence(timeout: 3) && columnsControl.buttons.count > 1 {
|
|
||||||
columnsControl.buttons.element(boundBy: 1).tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
app.buttons[IDs.settingsDone].tap()
|
|
||||||
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
|
||||||
}
|
|
||||||
|
|
||||||
func testLineHeightChangeUpdatesContent() throws {
|
func testLineHeightChangeUpdatesContent() throws {
|
||||||
app.launchAndOpenSampleBook()
|
app.launchAndOpenSampleBook()
|
||||||
app.waitForReader(timeout: 12)
|
app.waitForReader(timeout: 12)
|
||||||
|
|||||||
@ -36,18 +36,6 @@ final class SettingsExtendedTests: XCTestCase {
|
|||||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testColumnCountControl() {
|
|
||||||
openSettings()
|
|
||||||
let columns = app.segmentedControls[IDs.settingsColumns]
|
|
||||||
XCTAssertTrue(columns.waitForExistence(timeout: 3), "分栏控件未出现")
|
|
||||||
|
|
||||||
columns.buttons["单栏"].tap()
|
|
||||||
columns.buttons["双栏"].tap()
|
|
||||||
|
|
||||||
app.buttons[IDs.settingsDone].tap()
|
|
||||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDisplayTypeFromSettings() {
|
func testDisplayTypeFromSettings() {
|
||||||
openSettings()
|
openSettings()
|
||||||
let displayType = app.segmentedControls[IDs.settingsDisplayType]
|
let displayType = app.segmentedControls[IDs.settingsDisplayType]
|
||||||
@ -73,7 +61,6 @@ final class SettingsExtendedTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
|
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
|
||||||
XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3))
|
XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3))
|
||||||
XCTAssertTrue(app.segmentedControls[IDs.settingsColumns].waitForExistence(timeout: 3))
|
|
||||||
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3))
|
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -110,6 +110,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
|
|
||||||
public var note: String?
|
public var note: String?
|
||||||
|
|
||||||
|
/// True when this record was created directly as an annotation instead of
|
||||||
|
/// adding a note to an existing underline.
|
||||||
|
public var isAnnotationOnly: Bool
|
||||||
|
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@ -118,9 +122,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
location: RDEPUBLocation,
|
location: RDEPUBLocation,
|
||||||
text: String,
|
text: String,
|
||||||
rangeInfo: String? = nil,
|
rangeInfo: String? = nil,
|
||||||
style: RDEPUBHighlightStyle = .highlight,
|
style: RDEPUBHighlightStyle = .underline,
|
||||||
color: String = "#F8E16C",
|
color: String = "#F8E16C",
|
||||||
note: String? = nil,
|
note: String? = nil,
|
||||||
|
isAnnotationOnly: Bool = false,
|
||||||
createdAt: Date = Date()
|
createdAt: Date = Date()
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@ -131,6 +136,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
self.style = style
|
self.style = style
|
||||||
self.color = color
|
self.color = color
|
||||||
self.note = note?.nilIfEmpty
|
self.note = note?.nilIfEmpty
|
||||||
|
self.isAnnotationOnly = isAnnotationOnly
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +158,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
case style
|
case style
|
||||||
case color
|
case color
|
||||||
case note
|
case note
|
||||||
|
case isAnnotationOnly
|
||||||
case createdAt
|
case createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,12 +171,13 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
||||||
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
||||||
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
||||||
style = decodedStyle
|
style = decodedStyle == .highlight ? .underline : decodedStyle
|
||||||
} else {
|
} else {
|
||||||
style = .highlight
|
style = .underline
|
||||||
}
|
}
|
||||||
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
||||||
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
||||||
|
isAnnotationOnly = try container.decodeIfPresent(Bool.self, forKey: .isAnnotationOnly) ?? false
|
||||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,6 +191,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
try container.encode(style, forKey: .style)
|
try container.encode(style, forKey: .style)
|
||||||
try container.encode(color, forKey: .color)
|
try container.encode(color, forKey: .color)
|
||||||
try container.encodeIfPresent(note, forKey: .note)
|
try container.encodeIfPresent(note, forKey: .note)
|
||||||
|
try container.encode(isAnnotationOnly, forKey: .isAnnotationOnly)
|
||||||
try container.encode(createdAt, forKey: .createdAt)
|
try container.encode(createdAt, forKey: .createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,13 @@ public final class RDEPUBPublication {
|
|||||||
parser.readingProfile()
|
parser.readingProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scripted fixed-layout publications own a complete interactive scene per
|
||||||
|
/// spine item. Combining scenes in landscape can start multiple timelines
|
||||||
|
/// and media tracks, so these publications remain single-page.
|
||||||
|
public lazy var requiresSinglePagePresentation: Bool = {
|
||||||
|
layout == .fixed && parser.hasInteractiveContent()
|
||||||
|
}()
|
||||||
|
|
||||||
public var readingProgression: RDEPUBReadingProgression {
|
public var readingProgression: RDEPUBReadingProgression {
|
||||||
metadata.readingProgression
|
metadata.readingProgression
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,9 +56,9 @@ extension RDEPUBWebView {
|
|||||||
webView._editMenuInteraction = interaction
|
webView._editMenuInteraction = interaction
|
||||||
} else {
|
} else {
|
||||||
UIMenuController.shared.menuItems = [
|
UIMenuController.shared.menuItems = [
|
||||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
UIMenuItem(title: "复制", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
UIMenuItem(title: "划线", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||||
UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
UIMenuItem(title: "注释", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
webView.navigationDelegate = self
|
webView.navigationDelegate = self
|
||||||
@ -89,6 +89,11 @@ extension RDEPUBWebView {
|
|||||||
self.schemeHandler = schemeHandler
|
self.schemeHandler = schemeHandler
|
||||||
self.webView = webView
|
self.webView = webView
|
||||||
self.configuredPublicationKey = publicationKey
|
self.configuredPublicationKey = publicationKey
|
||||||
|
appliedNativeMediaPlaybackSuspended = false
|
||||||
|
isNativeMediaPlaybackOperationInFlight = false
|
||||||
|
nativeMediaPlaybackOperationGeneration &+= 1
|
||||||
|
nativeMediaPlaybackRequests.removeAll()
|
||||||
|
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
|
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,6 +108,7 @@ extension RDEPUBWebView {
|
|||||||
|
|
||||||
func teardownWebView() {
|
func teardownWebView() {
|
||||||
cancelFixedLayoutReadyFallback()
|
cancelFixedLayoutReadyFallback()
|
||||||
|
RDEPUBReaderMediaPlaybackCoordinator.shared.remove(self)
|
||||||
if let webView {
|
if let webView {
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||||
}
|
}
|
||||||
@ -119,6 +125,10 @@ extension RDEPUBWebView {
|
|||||||
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
|
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
|
||||||
webView?.removeFromSuperview()
|
webView?.removeFromSuperview()
|
||||||
webView = nil
|
webView = nil
|
||||||
|
finishAllNativeMediaPlaybackRequests()
|
||||||
|
isWaitingForReaderPageMediaNavigation = false
|
||||||
|
pendingReaderPageMediaNavigation = nil
|
||||||
|
isReaderPageMediaActivationBlocked = false
|
||||||
schemeHandler = nil
|
schemeHandler = nil
|
||||||
configuredPublicationKey = nil
|
configuredPublicationKey = nil
|
||||||
currentLoadSignature = nil
|
currentLoadSignature = nil
|
||||||
@ -143,11 +153,101 @@ private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serializes native media activation across page WebViews. WebKit suspension
|
||||||
|
/// is asynchronous and separate WKWebView instances have no ordering contract;
|
||||||
|
/// the incoming page therefore waits for the previous active page's suspension
|
||||||
|
/// completion before it can be resumed.
|
||||||
|
private final class RDEPUBReaderMediaPlaybackCoordinator {
|
||||||
|
|
||||||
|
static let shared = RDEPUBReaderMediaPlaybackCoordinator()
|
||||||
|
|
||||||
|
private weak var activePage: RDEPUBWebView?
|
||||||
|
private weak var pendingPage: RDEPUBWebView?
|
||||||
|
private var activationGeneration: UInt = 0
|
||||||
|
|
||||||
|
func setPlaybackSuspended(
|
||||||
|
_ suspended: Bool,
|
||||||
|
for page: RDEPUBWebView,
|
||||||
|
completion: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
|
if suspended {
|
||||||
|
suspend(page, completion: completion)
|
||||||
|
} else {
|
||||||
|
activate(page, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ page: RDEPUBWebView) {
|
||||||
|
if pendingPage === page {
|
||||||
|
activationGeneration &+= 1
|
||||||
|
}
|
||||||
|
if activePage === page {
|
||||||
|
activePage = nil
|
||||||
|
}
|
||||||
|
if pendingPage === page {
|
||||||
|
pendingPage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func suspend(_ page: RDEPUBWebView, completion: @escaping () -> Void) {
|
||||||
|
if pendingPage === page || (activePage === page && pendingPage == nil) {
|
||||||
|
activationGeneration &+= 1
|
||||||
|
}
|
||||||
|
if pendingPage === page {
|
||||||
|
pendingPage = nil
|
||||||
|
}
|
||||||
|
let suspensionGeneration = activationGeneration
|
||||||
|
|
||||||
|
page.applyNativeMediaPlaybackSuspended(true) { [weak self, weak page] in
|
||||||
|
guard let self, let page else { return }
|
||||||
|
if self.activationGeneration == suspensionGeneration,
|
||||||
|
self.activePage === page {
|
||||||
|
self.activePage = nil
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activate(_ page: RDEPUBWebView, completion: @escaping () -> Void) {
|
||||||
|
activationGeneration &+= 1
|
||||||
|
let generation = activationGeneration
|
||||||
|
pendingPage = page
|
||||||
|
let outgoingPage = activePage
|
||||||
|
|
||||||
|
let resumeIncomingPage = { [weak self, weak page] in
|
||||||
|
guard let self,
|
||||||
|
let page,
|
||||||
|
self.activationGeneration == generation,
|
||||||
|
self.pendingPage === page else { return }
|
||||||
|
self.pendingPage = nil
|
||||||
|
self.activePage = page
|
||||||
|
page.applyNativeMediaPlaybackSuspended(false) { [weak self, weak page] in
|
||||||
|
guard let self, let page else { return }
|
||||||
|
if self.activePage !== page {
|
||||||
|
page.applyNativeMediaPlaybackSuspended(true, completion: {})
|
||||||
|
} else {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let outgoingPage, outgoingPage !== page else {
|
||||||
|
resumeIncomingPage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoingPage.applyNativeMediaPlaybackSuspended(true) {
|
||||||
|
resumeIncomingPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension RDEPUBWebView {
|
extension RDEPUBWebView {
|
||||||
|
|
||||||
/// Installed in every frame before EPUB scripts run. Hidden pages start in
|
/// Installed in every frame before EPUB scripts run. Hidden pages start in
|
||||||
/// a suspended state, including audio created with `new Audio()` that is
|
/// a suspended state for both HTML media and Web Audio. Tumult Hype uses
|
||||||
/// not attached to the DOM (as used by Tumult Hype publications).
|
/// `AudioContext`/`AudioBufferSourceNode` for this publication, so handling
|
||||||
|
/// only `<audio>` and `new Audio()` leaves narration running on cached pages.
|
||||||
static let readerPageMediaLifecycleUserScript = #"""
|
static let readerPageMediaLifecycleUserScript = #"""
|
||||||
(() => {
|
(() => {
|
||||||
if (window.__rdReaderMediaLifecycleInstalled) return;
|
if (window.__rdReaderMediaLifecycleInstalled) return;
|
||||||
@ -155,10 +255,17 @@ extension RDEPUBWebView {
|
|||||||
|
|
||||||
let isActive = false;
|
let isActive = false;
|
||||||
const trackedMedia = new Set();
|
const trackedMedia = new Set();
|
||||||
|
const trackedAudioContexts = new Set();
|
||||||
|
const lifecycleSuspendedAudioContexts = new Set();
|
||||||
const command = "rd-reader-page-visibility";
|
const command = "rd-reader-page-visibility";
|
||||||
|
const frameReadyCommand = "rd-reader-frame-media-ready";
|
||||||
const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype;
|
const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype;
|
||||||
const nativePlay = mediaPrototype && mediaPrototype.play;
|
const nativePlay = mediaPrototype && mediaPrototype.play;
|
||||||
|
|
||||||
|
const ignoreRejection = result => {
|
||||||
|
if (result && result.catch) result.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
const register = media => {
|
const register = media => {
|
||||||
if (media) trackedMedia.add(media);
|
if (media) trackedMedia.add(media);
|
||||||
return media;
|
return media;
|
||||||
@ -169,17 +276,47 @@ extension RDEPUBWebView {
|
|||||||
document.querySelectorAll("audio,video").forEach(register);
|
document.querySelectorAll("audio,video").forEach(register);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateAudioContext = context => {
|
||||||
|
if (!context || context.state === "closed") return;
|
||||||
|
if (isActive) {
|
||||||
|
if (!lifecycleSuspendedAudioContexts.has(context)) return;
|
||||||
|
try {
|
||||||
|
const result = context.resume();
|
||||||
|
if (result && result.then) {
|
||||||
|
result.then(() => lifecycleSuspendedAudioContexts.delete(context)).catch(() => {});
|
||||||
|
} else {
|
||||||
|
lifecycleSuspendedAudioContexts.delete(context);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context.state !== "running" || lifecycleSuspendedAudioContexts.has(context)) return;
|
||||||
|
lifecycleSuspendedAudioContexts.add(context);
|
||||||
|
try { ignoreRejection(context.suspend()); } catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerAudioContext = context => {
|
||||||
|
if (context) {
|
||||||
|
trackedAudioContexts.add(context);
|
||||||
|
updateAudioContext(context);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
const setActive = active => {
|
const setActive = active => {
|
||||||
isActive = !!active;
|
isActive = !!active;
|
||||||
collectDocumentMedia();
|
collectDocumentMedia();
|
||||||
trackedMedia.forEach(media => {
|
trackedMedia.forEach(media => {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
if (media.__rdReaderReplayWhenVisible) {
|
if (media.__rdReaderReplayWhenVisible) {
|
||||||
media.__rdReaderReplayWhenVisible = false;
|
|
||||||
try {
|
try {
|
||||||
media.currentTime = 0;
|
media.currentTime = 0;
|
||||||
const result = nativePlay && nativePlay.call(media);
|
const result = nativePlay && nativePlay.call(media);
|
||||||
if (result && result.catch) result.catch(() => {});
|
if (result && result.then) {
|
||||||
|
result.then(() => { media.__rdReaderReplayWhenVisible = false; }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
media.__rdReaderReplayWhenVisible = false;
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -191,6 +328,7 @@ extension RDEPUBWebView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
trackedAudioContexts.forEach(updateAudioContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mediaPrototype && nativePlay) {
|
if (mediaPrototype && nativePlay) {
|
||||||
@ -218,34 +356,212 @@ extension RDEPUBWebView {
|
|||||||
window.Audio = ReaderAudio;
|
window.Audio = ReaderAudio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const installAudioContext = name => {
|
||||||
|
const NativeAudioContext = window[name];
|
||||||
|
if (typeof NativeAudioContext !== "function") return;
|
||||||
|
const ReaderAudioContext = function() {
|
||||||
|
const context = Reflect.construct(
|
||||||
|
NativeAudioContext,
|
||||||
|
Array.prototype.slice.call(arguments),
|
||||||
|
NativeAudioContext
|
||||||
|
);
|
||||||
|
return registerAudioContext(context);
|
||||||
|
};
|
||||||
|
ReaderAudioContext.prototype = NativeAudioContext.prototype;
|
||||||
|
try { Object.setPrototypeOf(ReaderAudioContext, NativeAudioContext); } catch (_) {}
|
||||||
|
try { window[name] = ReaderAudioContext; } catch (_) {}
|
||||||
|
};
|
||||||
|
installAudioContext("AudioContext");
|
||||||
|
installAudioContext("webkitAudioContext");
|
||||||
|
|
||||||
|
const sendVisibility = target => {
|
||||||
|
try {
|
||||||
|
target.postMessage({ type: command, active: isActive }, "*");
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const broadcastVisibility = () => {
|
||||||
|
for (let index = 0; index < window.frames.length; index += 1) {
|
||||||
|
sendVisibility(window.frames[index]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener("message", event => {
|
window.addEventListener("message", event => {
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
if (!message || message.type !== command) return;
|
if (!message) return;
|
||||||
setActive(message.active);
|
if (message.type === frameReadyCommand) {
|
||||||
for (let index = 0; index < window.frames.length; index += 1) {
|
if (event.source) sendVisibility(event.source);
|
||||||
try { window.frames[index].postMessage(message, "*"); } catch (_) {}
|
return;
|
||||||
}
|
}
|
||||||
|
if (message.type !== command) return;
|
||||||
|
setActive(message.active);
|
||||||
|
broadcastVisibility();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => setActive(isActive));
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
setActive(isActive);
|
||||||
|
broadcastVisibility();
|
||||||
|
});
|
||||||
if (window.MutationObserver) {
|
if (window.MutationObserver) {
|
||||||
new MutationObserver(() => setActive(isActive)).observe(document, {
|
new MutationObserver(() => setActive(isActive)).observe(document, {
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true
|
subtree: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (window.parent !== window) {
|
||||||
|
try { window.parent.postMessage({ type: frameReadyCommand }, "*"); } catch (_) {}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
"""#
|
"""#
|
||||||
|
|
||||||
func setReaderPageVisible(_ isVisible: Bool) {
|
func setReaderPageVisible(_ isVisible: Bool) {
|
||||||
|
let wasVisible = isReaderPageVisible
|
||||||
isReaderPageVisible = isVisible
|
isReaderPageVisible = isVisible
|
||||||
|
|
||||||
|
if !isVisible {
|
||||||
|
if wasVisible,
|
||||||
|
hasReaderPageBeenVisible,
|
||||||
|
currentRenderRequest?.isFixedLayout == true {
|
||||||
|
needsReaderPagePlaybackRestart = true
|
||||||
|
}
|
||||||
applyReaderPageMediaVisibility()
|
applyReaderPageMediaVisibility()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !wasVisible,
|
||||||
|
needsReaderPagePlaybackRestart,
|
||||||
|
restartReaderPagePlayback() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !isWaitingForReaderPageMediaNavigation else { return }
|
||||||
|
applyReaderPageMediaVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readerPageMediaNavigationDidFinish(_ navigation: WKNavigation?) -> Bool {
|
||||||
|
if isWaitingForReaderPageMediaNavigation,
|
||||||
|
let pendingReaderPageMediaNavigation,
|
||||||
|
let navigation,
|
||||||
|
pendingReaderPageMediaNavigation !== navigation {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pendingReaderPageMediaNavigation = nil
|
||||||
|
isReaderPageMediaActivationBlocked = false
|
||||||
|
isWaitingForReaderPageMediaNavigation = false
|
||||||
|
applyReaderPageMediaVisibility()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readerPageMediaNavigationDidFail(_ navigation: WKNavigation?) {
|
||||||
|
if let pendingReaderPageMediaNavigation,
|
||||||
|
let navigation,
|
||||||
|
pendingReaderPageMediaNavigation !== navigation {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingReaderPageMediaNavigation = nil
|
||||||
|
isReaderPageMediaActivationBlocked = true
|
||||||
|
isWaitingForReaderPageMediaNavigation = false
|
||||||
|
// A failed fixed-layout reload may leave the outgoing Hype document
|
||||||
|
// alive. Keep it suspended instead of reviving narration from the page
|
||||||
|
// the user already left; the next successful load will activate again.
|
||||||
|
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||||
|
webView?.evaluateJavaScript(
|
||||||
|
"window.postMessage({type: 'rd-reader-page-visibility', active: false}, '*');",
|
||||||
|
completionHandler: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyReaderPageMediaVisibility() {
|
func applyReaderPageMediaVisibility() {
|
||||||
guard let webView else { return }
|
guard let webView else { return }
|
||||||
let active = isReaderPageVisible ? "true" : "false"
|
let isVisible = isReaderPageVisible && !isReaderPageMediaActivationBlocked
|
||||||
let script = "window.postMessage({type: 'rd-reader-page-visibility', active: \(active)}, '*');"
|
if !isVisible {
|
||||||
webView.evaluateJavaScript(script, completionHandler: nil)
|
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||||
|
webView.evaluateJavaScript(
|
||||||
|
"window.postMessage({type: 'rd-reader-page-visibility', active: false}, '*');",
|
||||||
|
completionHandler: nil
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasReaderPageBeenVisible = true
|
||||||
|
setNativeReaderPageMediaPlaybackSuspended(false) { [weak self, weak webView] in
|
||||||
|
guard let self,
|
||||||
|
let webView,
|
||||||
|
self.webView === webView,
|
||||||
|
self.isReaderPageVisible,
|
||||||
|
!self.isReaderPageMediaActivationBlocked,
|
||||||
|
!self.isWaitingForReaderPageMediaNavigation else { return }
|
||||||
|
webView.evaluateJavaScript(
|
||||||
|
"window.postMessage({type: 'rd-reader-page-visibility', active: true}, '*');",
|
||||||
|
completionHandler: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartReaderPagePlayback() -> Bool {
|
||||||
|
guard let publication,
|
||||||
|
let currentRenderRequest,
|
||||||
|
currentRenderRequest.isFixedLayout else { return false }
|
||||||
|
load(publication: publication, request: currentRenderRequest)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNativeReaderPageMediaPlaybackSuspended(
|
||||||
|
_ suspended: Bool,
|
||||||
|
completion: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
|
RDEPUBReaderMediaPlaybackCoordinator.shared.setPlaybackSuspended(
|
||||||
|
suspended,
|
||||||
|
for: self,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyNativeMediaPlaybackSuspended(_ suspended: Bool, completion: @escaping () -> Void) {
|
||||||
|
nativeMediaPlaybackRequests.append((suspended, completion))
|
||||||
|
processNextNativeMediaPlaybackRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processNextNativeMediaPlaybackRequest() {
|
||||||
|
guard !isNativeMediaPlaybackOperationInFlight else { return }
|
||||||
|
guard let webView else {
|
||||||
|
finishAllNativeMediaPlaybackRequests()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let request = nativeMediaPlaybackRequests.first else { return }
|
||||||
|
|
||||||
|
guard appliedNativeMediaPlaybackSuspended != request.suspended else {
|
||||||
|
nativeMediaPlaybackRequests.removeFirst()
|
||||||
|
request.completion()
|
||||||
|
processNextNativeMediaPlaybackRequest()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetSuspended = request.suspended
|
||||||
|
isNativeMediaPlaybackOperationInFlight = true
|
||||||
|
nativeMediaPlaybackOperationGeneration &+= 1
|
||||||
|
let operationGeneration = nativeMediaPlaybackOperationGeneration
|
||||||
|
webView.setAllMediaPlaybackSuspended(targetSuspended) { [weak self, weak webView] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard operationGeneration == self.nativeMediaPlaybackOperationGeneration,
|
||||||
|
let webView,
|
||||||
|
self.webView === webView else { return }
|
||||||
|
|
||||||
|
self.appliedNativeMediaPlaybackSuspended = targetSuspended
|
||||||
|
self.isNativeMediaPlaybackOperationInFlight = false
|
||||||
|
guard let completedRequest = self.nativeMediaPlaybackRequests.first else { return }
|
||||||
|
self.nativeMediaPlaybackRequests.removeFirst()
|
||||||
|
completedRequest.completion()
|
||||||
|
self.processNextNativeMediaPlaybackRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishAllNativeMediaPlaybackRequests() {
|
||||||
|
let completions = nativeMediaPlaybackRequests.map(\.completion)
|
||||||
|
nativeMediaPlaybackRequests.removeAll()
|
||||||
|
nativeMediaPlaybackOperationGeneration &+= 1
|
||||||
|
appliedNativeMediaPlaybackSuspended = false
|
||||||
|
isNativeMediaPlaybackOperationInFlight = false
|
||||||
|
completions.forEach { $0() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ extension RDEPUBWebView {
|
|||||||
|
|
||||||
let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication)
|
let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication)
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
|
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
|
||||||
webView.loadHTMLString(
|
pendingReaderPageMediaNavigation = webView.loadHTMLString(
|
||||||
html,
|
html,
|
||||||
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
|
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -31,20 +31,33 @@ extension RDEPUBWebView: WKNavigationDelegate {
|
|||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
||||||
applyReaderPageMediaVisibility()
|
guard readerPageMediaNavigationDidFinish(navigation) else { return }
|
||||||
applyPresentation()
|
applyPresentation()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
|
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
|
||||||
|
// WebKit may not complete an in-flight media suspension after its
|
||||||
|
// content process exits. Tearing down flushes the FIFO callbacks and
|
||||||
|
// lets the coordinator activate another page without deadlocking.
|
||||||
|
// Rebuild the page afterward so a cached/current page does not remain
|
||||||
|
// permanently blank after WebKit recovers its content process.
|
||||||
|
let publication = publication
|
||||||
|
let renderRequest = currentRenderRequest
|
||||||
|
teardownWebView()
|
||||||
|
if let publication, let renderRequest {
|
||||||
|
load(publication: publication, request: renderRequest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
|
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
|
||||||
|
readerPageMediaNavigationDidFail(navigation)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
||||||
|
readerPageMediaNavigationDidFail(navigation)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
|||||||
@ -74,9 +74,9 @@ extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate {
|
|||||||
menuFor configuration: UIEditMenuConfiguration
|
menuFor configuration: UIEditMenuConfiguration
|
||||||
) -> UIMenu {
|
) -> UIMenu {
|
||||||
UIMenu(children: [
|
UIMenu(children: [
|
||||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
UICommand(title: "复制", action: #selector(rd_copy(_:))),
|
||||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
UICommand(title: "划线", action: #selector(rd_highlight(_:))),
|
||||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
UICommand(title: "注释", action: #selector(rd_annotate(_:)))
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,6 +138,24 @@ public final class RDEPUBWebView: UIView {
|
|||||||
|
|
||||||
var isReaderPageVisible = true
|
var isReaderPageVisible = true
|
||||||
|
|
||||||
|
var hasReaderPageBeenVisible = false
|
||||||
|
|
||||||
|
var needsReaderPagePlaybackRestart = false
|
||||||
|
|
||||||
|
var appliedNativeMediaPlaybackSuspended = false
|
||||||
|
|
||||||
|
var isNativeMediaPlaybackOperationInFlight = false
|
||||||
|
|
||||||
|
var nativeMediaPlaybackOperationGeneration: UInt = 0
|
||||||
|
|
||||||
|
var nativeMediaPlaybackRequests: [(suspended: Bool, completion: () -> Void)] = []
|
||||||
|
|
||||||
|
var isWaitingForReaderPageMediaNavigation = false
|
||||||
|
|
||||||
|
var pendingReaderPageMediaNavigation: WKNavigation?
|
||||||
|
|
||||||
|
var isReaderPageMediaActivationBlocked = false
|
||||||
|
|
||||||
var pendingProgressionRequest = false
|
var pendingProgressionRequest = false
|
||||||
|
|
||||||
var fixedLayoutReadyWorkItem: DispatchWorkItem?
|
var fixedLayoutReadyWorkItem: DispatchWorkItem?
|
||||||
@ -189,6 +207,11 @@ public final class RDEPUBWebView: UIView {
|
|||||||
fixedSpread = nil
|
fixedSpread = nil
|
||||||
isFixedLayout = false
|
isFixedLayout = false
|
||||||
isReaderPageVisible = true
|
isReaderPageVisible = true
|
||||||
|
hasReaderPageBeenVisible = false
|
||||||
|
needsReaderPagePlaybackRestart = false
|
||||||
|
isWaitingForReaderPageMediaNavigation = false
|
||||||
|
pendingReaderPageMediaNavigation = nil
|
||||||
|
isReaderPageMediaActivationBlocked = false
|
||||||
pendingProgressionRequest = false
|
pendingProgressionRequest = false
|
||||||
didRenderCurrentRequest = false
|
didRenderCurrentRequest = false
|
||||||
teardownWebView()
|
teardownWebView()
|
||||||
@ -207,6 +230,15 @@ public final class RDEPUBWebView: UIView {
|
|||||||
|
|
||||||
let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? ""
|
let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? ""
|
||||||
let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request)
|
let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request)
|
||||||
|
if request.isFixedLayout {
|
||||||
|
// A fixed-layout load replaces the whole Hype document. Keep WebKit
|
||||||
|
// suspended until navigation finishes so the outgoing page cannot
|
||||||
|
// overlap the new page's zero-second narration.
|
||||||
|
hasReaderPageBeenVisible = false
|
||||||
|
needsReaderPagePlaybackRestart = false
|
||||||
|
isWaitingForReaderPageMediaNavigation = true
|
||||||
|
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||||
|
}
|
||||||
RDEPUBWebViewDebug.log(
|
RDEPUBWebViewDebug.log(
|
||||||
debugScope,
|
debugScope,
|
||||||
message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))"
|
message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))"
|
||||||
|
|||||||
@ -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(chapterButton, systemName: "list.bullet", fallbackTitle: "目录")
|
||||||
configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
|
configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
|
||||||
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "批注")
|
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "标注")
|
||||||
configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
|
configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
|
||||||
configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
|
configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
|
||||||
chapterButton.accessibilityIdentifier = "epub.reader.toc"
|
chapterButton.accessibilityIdentifier = "epub.reader.toc"
|
||||||
|
|||||||
@ -130,10 +130,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
|||||||
|
|
||||||
func textContentView(
|
func textContentView(
|
||||||
_ contentView: RDEPUBTextContentView,
|
_ contentView: RDEPUBTextContentView,
|
||||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
|
||||||
sourceRect: CGRect
|
highlight: RDEPUBHighlight
|
||||||
) {
|
) {
|
||||||
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
|
runtime.handleHighlightMenuAction(action, highlight: highlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
|
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
|
||||||
|
|||||||
@ -5,14 +5,16 @@ extension RDEPUBReaderController {
|
|||||||
|
|
||||||
func applyReaderViewConfiguration() {
|
func applyReaderViewConfiguration() {
|
||||||
let resolvedDirection = resolvedPageDirection()
|
let resolvedDirection = resolvedPageDirection()
|
||||||
|
let allowsLandscapeDualPage = configuration.landscapeDualPageEnabled
|
||||||
|
&& publication?.requiresSinglePagePresentation != true
|
||||||
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|
||||||
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
|
|| readerView.landscapeDualPageEnabled != allowsLandscapeDualPage
|
||||||
|| readerView.pageDirection != resolvedDirection
|
|| readerView.pageDirection != resolvedDirection
|
||||||
let preservedLocation = presentationDidChange
|
let preservedLocation = presentationDidChange
|
||||||
? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
|
? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
|
||||||
: nil
|
: nil
|
||||||
view.backgroundColor = configuration.theme.contentBackgroundColor
|
view.backgroundColor = configuration.theme.contentBackgroundColor
|
||||||
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
|
readerView.landscapeDualPageEnabled = allowsLandscapeDualPage
|
||||||
readerView.pageDirection = resolvedDirection
|
readerView.pageDirection = resolvedDirection
|
||||||
updateReaderChrome()
|
updateReaderChrome()
|
||||||
if presentationDidChange {
|
if presentationDidChange {
|
||||||
|
|||||||
@ -374,14 +374,11 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
|
|
||||||
readerView.addSubview(searchBarView)
|
readerView.addSubview(searchBarView)
|
||||||
readerView.searchBarView = searchBarView
|
readerView.searchBarView = searchBarView
|
||||||
let bottomAnchor = bottomToolView.superview == nil
|
|
||||||
? readerView.bottomAnchor
|
|
||||||
: bottomToolView.topAnchor
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
||||||
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
||||||
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
||||||
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
searchBarView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor)
|
||||||
])
|
])
|
||||||
|
|
||||||
searchBarView.alpha = 0
|
searchBarView.alpha = 0
|
||||||
|
|||||||
@ -14,14 +14,14 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
|
|
||||||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||||||
|
|
||||||
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
|
private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"])
|
||||||
|
|
||||||
private var filteredHighlights: [RDEPUBHighlight] {
|
private var filteredHighlights: [RDEPUBHighlight] {
|
||||||
switch filterControl.selectedSegmentIndex {
|
switch filterControl.selectedSegmentIndex {
|
||||||
case 1:
|
case 1:
|
||||||
return highlights.filter(\.hasNote)
|
return highlights.filter(\.hasNote)
|
||||||
case 2:
|
case 2:
|
||||||
return highlights.filter { $0.style == .highlight }
|
return highlights.filter { !$0.hasNote }
|
||||||
default:
|
default:
|
||||||
return highlights
|
return highlights
|
||||||
}
|
}
|
||||||
@ -48,7 +48,11 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
view.accessibilityIdentifier = "epub.reader.highlights.panel"
|
view.accessibilityIdentifier = "epub.reader.highlights.panel"
|
||||||
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
|
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
|
||||||
tableView.tableFooterView = UIView(frame: .zero)
|
tableView.tableFooterView = UIView(frame: .zero)
|
||||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
tableView.separatorStyle = .none
|
||||||
|
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
|
||||||
|
navigationItem.rightBarButtonItem = editButtonItem
|
||||||
|
editButtonItem.title = "编辑"
|
||||||
|
tableView.allowsMultipleSelectionDuringEditing = false
|
||||||
configureFilterControl()
|
configureFilterControl()
|
||||||
applyTheme()
|
applyTheme()
|
||||||
updateEmptyState()
|
updateEmptyState()
|
||||||
@ -63,23 +67,42 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||||
let highlight = filteredHighlights[indexPath.row]
|
let highlight = filteredHighlights[indexPath.row]
|
||||||
|
|
||||||
cell.backgroundColor = theme.contentBackgroundColor
|
cell.backgroundColor = .clear
|
||||||
cell.textLabel?.textColor = theme.contentTextColor
|
cell.contentView.backgroundColor = UIColor.white.withAlphaComponent(0.76)
|
||||||
|
cell.contentView.layer.cornerRadius = 14
|
||||||
|
cell.contentView.layer.masksToBounds = true
|
||||||
|
cell.textLabel?.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
||||||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||||
cell.textLabel?.numberOfLines = 2
|
cell.textLabel?.numberOfLines = 2
|
||||||
cell.textLabel?.text = titleText(for: highlight)
|
cell.textLabel?.text = titleText(for: highlight)
|
||||||
|
|
||||||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
cell.detailTextLabel?.textColor = UIColor(red: 0.34, green: 0.36, blue: 0.40, alpha: 1)
|
||||||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||||||
cell.detailTextLabel?.numberOfLines = 3
|
cell.detailTextLabel?.numberOfLines = 3
|
||||||
cell.detailTextLabel?.text = detailText(for: highlight)
|
cell.detailTextLabel?.text = detailText(for: highlight)
|
||||||
cell.accessoryType = .disclosureIndicator
|
cell.accessoryType = .none
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 112 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
cell.contentView.frame = cell.bounds.inset(by: UIEdgeInsets(top: 6, left: 18, bottom: 6, right: 18))
|
||||||
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
guard !tableView.isEditing else { return }
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath)
|
onSelectHighlight?(filteredHighlights[indexPath.row])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
||||||
|
guard editingStyle == .delete else { return }
|
||||||
|
deleteHighlight(filteredHighlights[indexPath.row])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureFilterControl() {
|
private func configureFilterControl() {
|
||||||
@ -90,10 +113,10 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyTheme() {
|
private func applyTheme() {
|
||||||
tableView.backgroundColor = theme.contentBackgroundColor
|
tableView.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
||||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
navigationController?.navigationBar.barTintColor = tableView.backgroundColor
|
||||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)]
|
||||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
|
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,7 +143,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
private func emptyStateText() -> String {
|
private func emptyStateText() -> String {
|
||||||
switch filterControl.selectedSegmentIndex {
|
switch filterControl.selectedSegmentIndex {
|
||||||
case 1:
|
case 1:
|
||||||
return "暂无批注"
|
return "暂无注释"
|
||||||
case 2:
|
case 2:
|
||||||
return "暂无划线"
|
return "暂无划线"
|
||||||
default:
|
default:
|
||||||
@ -142,7 +165,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
private func titleText(for highlight: RDEPUBHighlight) -> String {
|
private func titleText(for highlight: RDEPUBHighlight) -> String {
|
||||||
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if highlight.hasNote {
|
if highlight.hasNote {
|
||||||
return "批注: \(text)"
|
return "注释:\(text)"
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
@ -150,58 +173,12 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
|
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
|
||||||
switch highlight.style {
|
switch highlight.style {
|
||||||
case .highlight:
|
case .highlight:
|
||||||
return highlight.hasNote ? "高亮批注" : "高亮"
|
return highlight.hasNote ? "划线注释" : "划线"
|
||||||
case .underline:
|
case .underline:
|
||||||
return highlight.hasNote ? "划线批注" : "划线"
|
return highlight.hasNote ? "划线注释" : "划线"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentActions(for highlight: RDEPUBHighlight, sourceIndexPath: IndexPath) {
|
|
||||||
let alert = UIAlertController(title: "标注管理", message: nil, preferredStyle: .actionSheet)
|
|
||||||
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
|
|
||||||
self?.onSelectHighlight?(highlight)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "编辑批注", style: .default) { [weak self] _ in
|
|
||||||
self?.presentNoteEditor(for: highlight)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "删除标注", style: .destructive) { [weak self] _ in
|
|
||||||
self?.deleteHighlight(highlight)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
|
|
||||||
if let popover = alert.popoverPresentationController,
|
|
||||||
let cell = tableView.cellForRow(at: sourceIndexPath) {
|
|
||||||
popover.sourceView = cell
|
|
||||||
popover.sourceRect = cell.bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func presentNoteEditor(for highlight: RDEPUBHighlight) {
|
|
||||||
let alert = UIAlertController(title: "编辑批注", message: nil, preferredStyle: .alert)
|
|
||||||
alert.addTextField { textField in
|
|
||||||
textField.placeholder = "输入批注内容"
|
|
||||||
textField.text = highlight.note
|
|
||||||
}
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
|
||||||
guard let self,
|
|
||||||
let note = alert?.textFields?.first?.text,
|
|
||||||
let index = self.highlights.firstIndex(where: { $0.id == highlight.id }) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var updated = highlight
|
|
||||||
updated.note = self.normalizedNote(note)
|
|
||||||
self.highlights[index] = updated
|
|
||||||
self.tableView.reloadData()
|
|
||||||
self.onUpdateHighlight?(updated)
|
|
||||||
self.updateEmptyState()
|
|
||||||
})
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteHighlight(_ highlight: RDEPUBHighlight) {
|
private func deleteHighlight(_ highlight: RDEPUBHighlight) {
|
||||||
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
|
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
|
||||||
highlights.remove(at: index)
|
highlights.remove(at: index)
|
||||||
@ -248,7 +225,10 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
|||||||
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
|
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
|
||||||
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
||||||
tableView.tableFooterView = UIView(frame: .zero)
|
tableView.tableFooterView = UIView(frame: .zero)
|
||||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
tableView.separatorStyle = .none
|
||||||
|
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
|
||||||
|
navigationItem.rightBarButtonItem = editButtonItem
|
||||||
|
editButtonItem.title = "编辑"
|
||||||
applyTheme()
|
applyTheme()
|
||||||
updateEmptyState()
|
updateEmptyState()
|
||||||
}
|
}
|
||||||
@ -262,30 +242,40 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
|||||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||||
let bookmark = bookmarks[indexPath.row]
|
let bookmark = bookmarks[indexPath.row]
|
||||||
|
|
||||||
cell.backgroundColor = theme.contentBackgroundColor
|
cell.backgroundColor = .clear
|
||||||
cell.textLabel?.textColor = theme.contentTextColor
|
cell.contentView.backgroundColor = UIColor.white.withAlphaComponent(0.76)
|
||||||
|
cell.contentView.layer.cornerRadius = 14
|
||||||
|
cell.contentView.layer.masksToBounds = true
|
||||||
|
cell.textLabel?.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
||||||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||||
cell.textLabel?.numberOfLines = 2
|
cell.textLabel?.numberOfLines = 2
|
||||||
cell.textLabel?.text = titleText(for: bookmark)
|
cell.textLabel?.text = titleText(for: bookmark)
|
||||||
|
|
||||||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
cell.detailTextLabel?.textColor = UIColor(red: 0.34, green: 0.36, blue: 0.40, alpha: 1)
|
||||||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||||||
cell.detailTextLabel?.numberOfLines = 3
|
cell.detailTextLabel?.numberOfLines = 3
|
||||||
cell.detailTextLabel?.text = detailText(for: bookmark)
|
cell.detailTextLabel?.text = detailText(for: bookmark)
|
||||||
cell.accessoryType = .disclosureIndicator
|
cell.accessoryType = .none
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 104 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
||||||
|
guard editingStyle == .delete else { return }
|
||||||
|
deleteBookmark(bookmarks[indexPath.row])
|
||||||
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath)
|
onSelectBookmark?(bookmarks[indexPath.row])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTheme() {
|
private func applyTheme() {
|
||||||
tableView.backgroundColor = theme.contentBackgroundColor
|
tableView.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
||||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
navigationController?.navigationBar.barTintColor = tableView.backgroundColor
|
||||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)]
|
||||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,25 +312,6 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
|||||||
return parts.joined(separator: "\n")
|
return parts.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentActions(for bookmark: RDEPUBBookmark, sourceIndexPath: IndexPath) {
|
|
||||||
let alert = UIAlertController(title: "书签管理", message: nil, preferredStyle: .actionSheet)
|
|
||||||
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
|
|
||||||
self?.onSelectBookmark?(bookmark)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "删除书签", style: .destructive) { [weak self] _ in
|
|
||||||
self?.deleteBookmark(bookmark)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
|
|
||||||
if let popover = alert.popoverPresentationController,
|
|
||||||
let cell = tableView.cellForRow(at: sourceIndexPath) {
|
|
||||||
popover.sourceView = cell
|
|
||||||
popover.sourceRect = cell.bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
|
private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
|
||||||
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
|
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
|
||||||
bookmarks.remove(at: index)
|
bookmarks.remove(at: index)
|
||||||
|
|||||||
@ -119,21 +119,13 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
super.apply(theme: theme)
|
super.apply(theme: theme)
|
||||||
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
|
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
|
||||||
|
|
||||||
let overlayColor = isDarkBackground
|
let overlayColor = UIColor(white: 0.05, alpha: 0.32)
|
||||||
? UIColor(white: 0.12, alpha: 0.92)
|
let panelColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
||||||
: UIColor(white: 0.08, alpha: 0.82)
|
let rowColor = UIColor.white.withAlphaComponent(0.88)
|
||||||
let panelColor = isDarkBackground
|
let cardColor = UIColor.white.withAlphaComponent(0.78)
|
||||||
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
|
let activeCardColor = UIColor(red: 0.90, green: 0.94, blue: 1.0, alpha: 1)
|
||||||
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
|
let textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
||||||
let rowColor = isDarkBackground
|
let secondaryTextColor = UIColor(red: 0.38, green: 0.40, blue: 0.44, alpha: 1)
|
||||||
? UIColor(white: 0.18, alpha: 1)
|
|
||||||
: UIColor(white: 0.14, alpha: 0.96)
|
|
||||||
let cardColor = isDarkBackground
|
|
||||||
? UIColor(white: 0.12, alpha: 1)
|
|
||||||
: UIColor(white: 0.10, alpha: 0.98)
|
|
||||||
let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
|
|
||||||
let textColor = UIColor(white: 0.96, alpha: 1)
|
|
||||||
let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
|
|
||||||
|
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
backgroundButton.backgroundColor = overlayColor
|
backgroundButton.backgroundColor = overlayColor
|
||||||
@ -141,7 +133,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
|
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
|
||||||
searchRowView.backgroundColor = rowColor
|
searchRowView.backgroundColor = rowColor
|
||||||
searchFieldContainer.backgroundColor = .clear
|
searchFieldContainer.backgroundColor = .clear
|
||||||
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
|
searchFieldDivider.backgroundColor = UIColor.black.withAlphaComponent(0.10)
|
||||||
searchIcon.tintColor = secondaryTextColor
|
searchIcon.tintColor = secondaryTextColor
|
||||||
cancelButton.tintColor = textColor
|
cancelButton.tintColor = textColor
|
||||||
cancelButton.setTitleColor(textColor, for: .normal)
|
cancelButton.setTitleColor(textColor, for: .normal)
|
||||||
@ -165,8 +157,8 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
|
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
|
||||||
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
|
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
|
||||||
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
|
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
|
||||||
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
|
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||||
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||||
|
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
@ -274,10 +266,10 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
searchIcon.contentMode = .scaleAspectFit
|
searchIcon.contentMode = .scaleAspectFit
|
||||||
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
||||||
|
|
||||||
panelView.layer.cornerRadius = 28
|
panelView.layer.cornerRadius = 0
|
||||||
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
|
||||||
panelView.clipsToBounds = true
|
panelView.clipsToBounds = true
|
||||||
|
|
||||||
|
grabberView.isHidden = true
|
||||||
grabberView.layer.cornerRadius = 3
|
grabberView.layer.cornerRadius = 3
|
||||||
searchRowView.layer.cornerRadius = 22
|
searchRowView.layer.cornerRadius = 22
|
||||||
searchFieldContainer.layer.cornerRadius = 22
|
searchFieldContainer.layer.cornerRadius = 22
|
||||||
@ -317,7 +309,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
|
|
||||||
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
|
panelView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
|
||||||
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
|
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
|
||||||
@ -327,7 +319,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
|
|
||||||
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
||||||
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
||||||
searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
|
searchRowView.topAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.topAnchor, constant: 12),
|
||||||
searchRowView.heightAnchor.constraint(equalToConstant: 52),
|
searchRowView.heightAnchor.constraint(equalToConstant: 52),
|
||||||
|
|
||||||
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
|
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
|
||||||
@ -480,7 +472,7 @@ extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate
|
|||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
|
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
|
||||||
label.textColor = UIColor(white: 0.96, alpha: 1)
|
label.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
||||||
label.text = searchSections[section].title
|
label.text = searchSections[section].title
|
||||||
label.numberOfLines = 2
|
label.numberOfLines = 2
|
||||||
container.addSubview(label)
|
container.addSubview(label)
|
||||||
|
|||||||
@ -339,6 +339,7 @@ public final class RDEpubURLReaderController: UIViewController {
|
|||||||
let state = [
|
let state = [
|
||||||
"reader=opened",
|
"reader=opened",
|
||||||
"page=\(page)",
|
"page=\(page)",
|
||||||
|
"pagesPerScreen=\(readerController?.readerView.pagesPerScreen ?? 1)",
|
||||||
"display=\(display)",
|
"display=\(display)",
|
||||||
"toolbar=\(toolbar)",
|
"toolbar=\(toolbar)",
|
||||||
"highlights=\(highlights)",
|
"highlights=\(highlights)",
|
||||||
|
|||||||
@ -53,7 +53,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
color: String = "#F8E16C",
|
color: String = "#F8E16C",
|
||||||
note: String? = nil
|
note: String? = nil
|
||||||
) -> RDEPUBHighlight? {
|
) -> RDEPUBHighlight? {
|
||||||
addAnnotation(from: selection, style: .highlight, color: color, note: note)
|
addAnnotation(from: selection, style: .underline, color: color, note: note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@ -61,7 +61,8 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
from selection: RDEPUBSelection? = nil,
|
from selection: RDEPUBSelection? = nil,
|
||||||
style: RDEPUBHighlightStyle,
|
style: RDEPUBHighlightStyle,
|
||||||
color: String = "#F8E16C",
|
color: String = "#F8E16C",
|
||||||
note: String? = nil
|
note: String? = nil,
|
||||||
|
isAnnotationOnly: Bool = false
|
||||||
) -> RDEPUBHighlight? {
|
) -> RDEPUBHighlight? {
|
||||||
guard let controller else { return nil }
|
guard let controller else { return nil }
|
||||||
let sourceSelection = selection ?? controller.currentSelection
|
let sourceSelection = selection ?? controller.currentSelection
|
||||||
@ -71,22 +72,35 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let newHighlight = RDEPUBHighlight(
|
var newHighlight = RDEPUBHighlight(
|
||||||
bookIdentifier: controller.currentBookIdentifier,
|
bookIdentifier: controller.currentBookIdentifier,
|
||||||
location: scopedSelection.location,
|
location: scopedSelection.location,
|
||||||
text: scopedSelection.text,
|
text: scopedSelection.text,
|
||||||
rangeInfo: scopedSelection.rangeInfo,
|
rangeInfo: scopedSelection.rangeInfo,
|
||||||
style: style,
|
style: .underline,
|
||||||
color: color,
|
color: color,
|
||||||
note: note
|
note: note,
|
||||||
|
isAnnotationOnly: isAnnotationOnly
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let mergeCandidates = controller.activeHighlights.filter {
|
||||||
|
shouldMerge($0, with: newHighlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !mergeCandidates.isEmpty {
|
||||||
|
newHighlight = mergedHighlight(newHighlight, with: mergeCandidates)
|
||||||
|
controller.activeHighlights.removeAll { candidate in
|
||||||
|
mergeCandidates.contains { $0.id == candidate.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let isDuplicate = controller.activeHighlights.contains { highlight in
|
let isDuplicate = controller.activeHighlights.contains { highlight in
|
||||||
highlight.location.href == newHighlight.location.href &&
|
highlight.location.href == newHighlight.location.href &&
|
||||||
highlight.location.fragment == newHighlight.location.fragment &&
|
highlight.location.fragment == newHighlight.location.fragment &&
|
||||||
highlight.text == newHighlight.text &&
|
highlight.text == newHighlight.text &&
|
||||||
highlight.rangeInfo == newHighlight.rangeInfo &&
|
highlight.rangeInfo == newHighlight.rangeInfo &&
|
||||||
highlight.style == newHighlight.style
|
highlight.style == newHighlight.style &&
|
||||||
|
highlight.isAnnotationOnly == newHighlight.isAnnotationOnly
|
||||||
}
|
}
|
||||||
guard !isDuplicate else {
|
guard !isDuplicate else {
|
||||||
return nil
|
return nil
|
||||||
@ -219,25 +233,29 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
presentAnnotationActionSheet(for: currentSelection)
|
presentAnnotationActionSheet(for: currentSelection)
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
|
||||||
guard let controller else { return }
|
switch action {
|
||||||
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
|
case .copy:
|
||||||
alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in
|
UIPasteboard.general.string = highlight.text
|
||||||
_ = self?.removeHighlight(id: highlight.id)
|
case .createUnderline:
|
||||||
})
|
guard highlight.isAnnotationOnly,
|
||||||
if highlight.hasNote {
|
let controller,
|
||||||
alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in
|
let index = controller.activeHighlights.firstIndex(where: { $0.id == highlight.id }) else {
|
||||||
_ = self?.updateHighlightNote(id: highlight.id, note: nil)
|
return
|
||||||
})
|
}
|
||||||
|
controller.activeHighlights[index].isAnnotationOnly = false
|
||||||
|
persistHighlightsAndRefreshContent()
|
||||||
|
case .deleteUnderline:
|
||||||
|
_ = removeHighlight(id: highlight.id)
|
||||||
|
case .annotate:
|
||||||
|
presentAnnotationNoteEditor(for: highlight)
|
||||||
|
case .deleteAnnotation:
|
||||||
|
if highlight.isAnnotationOnly {
|
||||||
|
_ = removeHighlight(id: highlight.id)
|
||||||
|
} else {
|
||||||
|
_ = updateHighlightNote(id: highlight.id, note: nil)
|
||||||
}
|
}
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
|
|
||||||
if let popover = alert.popoverPresentationController {
|
|
||||||
popover.sourceView = sourceView
|
|
||||||
popover.sourceRect = sourceRect
|
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.present(alert, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||||
@ -247,7 +265,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
UIPasteboard.general.string = selection.text
|
UIPasteboard.general.string = selection.text
|
||||||
updateCurrentSelection(nil)
|
updateCurrentSelection(nil)
|
||||||
case .highlight:
|
case .highlight:
|
||||||
createAnnotation(from: selection, style: .highlight)
|
createAnnotation(from: selection, style: .underline)
|
||||||
case .annotate:
|
case .annotate:
|
||||||
presentAnnotationNoteEditor(for: selection)
|
presentAnnotationNoteEditor(for: selection)
|
||||||
}
|
}
|
||||||
@ -365,13 +383,65 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
location: normalizedLocation,
|
location: normalizedLocation,
|
||||||
text: highlight.text,
|
text: highlight.text,
|
||||||
rangeInfo: highlight.rangeInfo,
|
rangeInfo: highlight.rangeInfo,
|
||||||
style: highlight.style,
|
style: .underline,
|
||||||
color: highlight.color,
|
color: highlight.color,
|
||||||
note: highlight.note,
|
note: highlight.note,
|
||||||
|
isAnnotationOnly: highlight.isAnnotationOnly,
|
||||||
createdAt: highlight.createdAt
|
createdAt: highlight.createdAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func shouldMerge(_ existing: RDEPUBHighlight, with incoming: RDEPUBHighlight) -> Bool {
|
||||||
|
guard existing.location.href == incoming.location.href,
|
||||||
|
let existingRange = RDEPUBTextOffsetRangeInfo.decode(from: existing.rangeInfo)?.nsRange,
|
||||||
|
let incomingRange = RDEPUBTextOffsetRangeInfo.decode(from: incoming.rangeInfo)?.nsRange else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Only merge identical selections. Contained or partially-overlapping
|
||||||
|
// ranges remain independent so a reader can still manage each mark.
|
||||||
|
return existingRange.location == incomingRange.location
|
||||||
|
&& existingRange.length == incomingRange.length
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mergedHighlight(
|
||||||
|
_ incoming: RDEPUBHighlight,
|
||||||
|
with existingHighlights: [RDEPUBHighlight]
|
||||||
|
) -> RDEPUBHighlight {
|
||||||
|
var result = incoming
|
||||||
|
let allHighlights = existingHighlights + [incoming]
|
||||||
|
let ranges = allHighlights.compactMap {
|
||||||
|
RDEPUBTextOffsetRangeInfo.decode(from: $0.rangeInfo)
|
||||||
|
}
|
||||||
|
if let first = ranges.first {
|
||||||
|
let start = ranges.map(\.start).min() ?? first.start
|
||||||
|
let end = ranges.map(\.end).max() ?? first.end
|
||||||
|
result.rangeInfo = RDEPUBTextOffsetRangeInfo(href: first.href, start: start, end: end).jsonString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest selection is exact whenever it encloses the merged range;
|
||||||
|
// otherwise keep every distinct excerpt rather than silently losing
|
||||||
|
// text that may later be copied from the marking list.
|
||||||
|
if let incomingRange = RDEPUBTextOffsetRangeInfo.decode(from: incoming.rangeInfo),
|
||||||
|
let mergedRange = RDEPUBTextOffsetRangeInfo.decode(from: result.rangeInfo),
|
||||||
|
incomingRange.start <= mergedRange.start,
|
||||||
|
incomingRange.end >= mergedRange.end {
|
||||||
|
result.text = incoming.text
|
||||||
|
} else {
|
||||||
|
result.text = allHighlights.map(\.text).reduce(into: [String]()) { texts, text in
|
||||||
|
if !texts.contains(text) { texts.append(text) }
|
||||||
|
}.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
let notes = allHighlights.compactMap(\.note).map {
|
||||||
|
$0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}.filter { !$0.isEmpty }
|
||||||
|
let uniqueNotes = notes.reduce(into: [String]()) { values, note in
|
||||||
|
if !values.contains(note) { values.append(note) }
|
||||||
|
}
|
||||||
|
result.note = uniqueNotes.isEmpty ? nil : uniqueNotes.joined(separator: "\n\n")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
|
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
|
||||||
guard let controller else { return false }
|
guard let controller else { return false }
|
||||||
@ -410,13 +480,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
|
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
|
||||||
guard let controller else { return }
|
guard let controller else { return }
|
||||||
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
|
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
|
||||||
alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in
|
|
||||||
self?.createAnnotation(from: selection, style: .highlight)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
|
||||||
self?.createAnnotation(from: selection, style: .underline)
|
self?.createAnnotation(from: selection, style: .underline)
|
||||||
})
|
})
|
||||||
alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "注释", style: .default) { [weak self] _ in
|
||||||
self?.presentAnnotationNoteEditor(for: selection)
|
self?.presentAnnotationNoteEditor(for: selection)
|
||||||
})
|
})
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
@ -429,25 +496,48 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
controller.present(alert, animated: true)
|
controller.present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
|
private func createAnnotation(
|
||||||
_ = addAnnotation(from: selection, style: style, note: note)
|
from selection: RDEPUBSelection,
|
||||||
|
style: RDEPUBHighlightStyle,
|
||||||
|
note: String? = nil,
|
||||||
|
isAnnotationOnly: Bool = false
|
||||||
|
) {
|
||||||
|
_ = addAnnotation(
|
||||||
|
from: selection,
|
||||||
|
style: style,
|
||||||
|
note: note,
|
||||||
|
isAnnotationOnly: isAnnotationOnly
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
|
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
|
||||||
guard let controller else { return }
|
presentNoteEditor(quote: selection.text, initialNote: nil) { [weak self] note in
|
||||||
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert)
|
guard note != nil else { return }
|
||||||
alert.addTextField { textField in
|
|
||||||
textField.placeholder = "输入批注内容"
|
|
||||||
}
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
|
||||||
self?.createAnnotation(
|
self?.createAnnotation(
|
||||||
from: selection,
|
from: selection,
|
||||||
style: .highlight,
|
style: .underline,
|
||||||
note: alert?.textFields?.first?.text
|
note: note,
|
||||||
|
isAnnotationOnly: true
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
controller.present(alert, animated: true)
|
}
|
||||||
|
|
||||||
|
private func presentAnnotationNoteEditor(for highlight: RDEPUBHighlight) {
|
||||||
|
presentNoteEditor(quote: highlight.text, initialNote: highlight.note) { [weak self] note in
|
||||||
|
_ = self?.updateHighlightNote(id: highlight.id, note: note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentNoteEditor(quote: String, initialNote: String?, onSave: @escaping (String?) -> Void) {
|
||||||
|
guard let controller else { return }
|
||||||
|
let editor = RDEPUBAnnotationEditorViewController(
|
||||||
|
quote: quote,
|
||||||
|
initialNote: initialNote,
|
||||||
|
onSave: onSave
|
||||||
|
)
|
||||||
|
let navigationController = UINavigationController(rootViewController: editor)
|
||||||
|
navigationController.modalPresentationStyle = .pageSheet
|
||||||
|
controller.present(navigationController, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
|
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
|
||||||
|
|||||||
@ -4,6 +4,8 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
|||||||
|
|
||||||
private unowned let context: RDEPUBReaderContext
|
private unowned let context: RDEPUBReaderContext
|
||||||
|
|
||||||
|
private var settingsTransitionDelegate: RDEPUBReaderSettingsTransitionDelegate?
|
||||||
|
|
||||||
init(context: RDEPUBReaderContext) {
|
init(context: RDEPUBReaderContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
}
|
}
|
||||||
@ -122,9 +124,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
|||||||
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
|
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
|
||||||
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
|
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
|
||||||
}
|
}
|
||||||
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
|
|
||||||
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
|
|
||||||
}
|
|
||||||
settingsController.onDisplayTypeChange = { [weak controller] displayType in
|
settingsController.onDisplayTypeChange = { [weak controller] displayType in
|
||||||
controller?.updateConfiguration { $0.displayType = displayType }
|
controller?.updateConfiguration { $0.displayType = displayType }
|
||||||
}
|
}
|
||||||
@ -137,8 +136,13 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
|||||||
}
|
}
|
||||||
|
|
||||||
let navigationController = UINavigationController(rootViewController: settingsController)
|
let navigationController = UINavigationController(rootViewController: settingsController)
|
||||||
navigationController.modalPresentationStyle = .pageSheet
|
let transitionDelegate = RDEPUBReaderSettingsTransitionDelegate()
|
||||||
navigationController.presentationController?.delegate = self
|
settingsTransitionDelegate = transitionDelegate
|
||||||
|
navigationController.setNavigationBarHidden(true, animated: false)
|
||||||
|
navigationController.view.backgroundColor = .clear
|
||||||
|
navigationController.view.isOpaque = false
|
||||||
|
navigationController.modalPresentationStyle = .custom
|
||||||
|
navigationController.transitioningDelegate = transitionDelegate
|
||||||
controller.present(navigationController, animated: true)
|
controller.present(navigationController, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,3 +237,77 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class RDEPUBReaderSettingsTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate {
|
||||||
|
|
||||||
|
func presentationController(
|
||||||
|
forPresented presented: UIViewController,
|
||||||
|
presenting: UIViewController?,
|
||||||
|
source: UIViewController
|
||||||
|
) -> UIPresentationController? {
|
||||||
|
RDEPUBReaderSettingsPresentationController(
|
||||||
|
presentedViewController: presented,
|
||||||
|
presenting: presenting ?? source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class RDEPUBReaderSettingsPresentationController: UIPresentationController {
|
||||||
|
|
||||||
|
private let dimmingView = UIControl()
|
||||||
|
|
||||||
|
override var frameOfPresentedViewInContainerView: CGRect {
|
||||||
|
guard let containerView else { return .zero }
|
||||||
|
let bounds = containerView.bounds
|
||||||
|
let isLandscape = bounds.width > bounds.height
|
||||||
|
if isLandscape {
|
||||||
|
let width = min(390, max(300, bounds.width * 0.32))
|
||||||
|
let height = min(340, bounds.height - 32)
|
||||||
|
return CGRect(
|
||||||
|
x: bounds.maxX - width - 16,
|
||||||
|
y: bounds.midY - height / 2,
|
||||||
|
width: width,
|
||||||
|
height: height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let height = min(350, max(280, bounds.height * 0.40))
|
||||||
|
return CGRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func presentationTransitionWillBegin() {
|
||||||
|
guard let containerView else { return }
|
||||||
|
dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.18)
|
||||||
|
dimmingView.alpha = 0
|
||||||
|
dimmingView.addTarget(self, action: #selector(dismissPresentedController), for: .touchUpInside)
|
||||||
|
dimmingView.frame = containerView.bounds
|
||||||
|
containerView.insertSubview(dimmingView, at: 0)
|
||||||
|
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
|
||||||
|
self.dimmingView.alpha = 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override func dismissalTransitionWillBegin() {
|
||||||
|
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
|
||||||
|
self.dimmingView.alpha = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override func containerViewWillLayoutSubviews() {
|
||||||
|
super.containerViewWillLayoutSubviews()
|
||||||
|
dimmingView.frame = containerView?.bounds ?? .zero
|
||||||
|
presentedView?.frame = frameOfPresentedViewInContainerView
|
||||||
|
}
|
||||||
|
|
||||||
|
override func dismissalTransitionDidEnd(_ completed: Bool) {
|
||||||
|
super.dismissalTransitionDidEnd(completed)
|
||||||
|
if completed { dimmingView.removeFromSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dismissPresentedController() {
|
||||||
|
if let navigationController = presentedViewController as? UINavigationController,
|
||||||
|
let settingsController = navigationController.topViewController as? RDEPUBReaderSettingsViewController {
|
||||||
|
settingsController.notifyDismissalIfNeeded()
|
||||||
|
}
|
||||||
|
presentedViewController.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -163,7 +163,11 @@ final class RDEPUBReaderContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func currentPreferences(pageIndex: Int? = nil) -> RDEPUBPreferences {
|
func currentPreferences(pageIndex: Int? = nil) -> RDEPUBPreferences {
|
||||||
environment.currentPreferences(configuration: configuration, pageIndex: pageIndex)
|
var preferences = environment.currentPreferences(configuration: configuration, pageIndex: pageIndex)
|
||||||
|
if publication?.requiresSinglePagePresentation == true {
|
||||||
|
preferences.fixedLayoutSpreadMode = .never
|
||||||
|
}
|
||||||
|
return preferences
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentTextContentInsets(pageIndex: Int) -> UIEdgeInsets {
|
func currentTextContentInsets(pageIndex: Int) -> UIEdgeInsets {
|
||||||
|
|||||||
@ -354,10 +354,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
|
||||||
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
|
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
|
||||||
guard publication.spine.indices.contains(index) else { return false }
|
guard publication.spine.indices.contains(index) else { return false }
|
||||||
let item = publication.spine[index]
|
let item = publication.spine[index]
|
||||||
|
|||||||
@ -256,8 +256,8 @@ final class RDEPUBReaderRuntime {
|
|||||||
annotationCoordinator.presentAnnotationCreation()
|
annotationCoordinator.presentAnnotationCreation()
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
|
||||||
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
|
annotationCoordinator.handleHighlightMenuAction(action, highlight: highlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||||
|
|||||||
@ -1,30 +1,19 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// Compact reader settings drawer. It deliberately keeps the book visible and
|
||||||
|
/// exposes only the controls currently supported by the reader configuration.
|
||||||
final class RDEPUBReaderSettingsViewController: UIViewController {
|
final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||||
|
|
||||||
var onBrightnessChange: ((CGFloat) -> Void)?
|
var onBrightnessChange: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
|
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
|
||||||
|
|
||||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
var onColumnCountChange: ((Int) -> Void)?
|
|
||||||
|
|
||||||
var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
|
var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
|
||||||
|
|
||||||
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
||||||
|
|
||||||
var onDismiss: (() -> Void)?
|
var onDismiss: (() -> Void)?
|
||||||
|
|
||||||
private enum ThemePreset: Int, CaseIterable {
|
private enum ThemePreset: Int, CaseIterable {
|
||||||
case light
|
case light, yellow, green, pink, blue, dark
|
||||||
case yellow
|
|
||||||
case green
|
|
||||||
case pink
|
|
||||||
case blue
|
|
||||||
case dark
|
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
@ -49,50 +38,24 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
private let handleView = UIView()
|
||||||
|
private let closeButton = UIButton(type: .system)
|
||||||
private let contentStack: UIStackView = {
|
|
||||||
let stackView = UIStackView()
|
|
||||||
stackView.axis = .vertical
|
|
||||||
stackView.spacing = 20
|
|
||||||
return stackView
|
|
||||||
}()
|
|
||||||
|
|
||||||
private let brightnessSlider = UISlider()
|
private let brightnessSlider = UISlider()
|
||||||
|
private let fontSizeSlider = UISlider()
|
||||||
private let fontValueLabel = UILabel()
|
private let fontSizeLabel = UILabel()
|
||||||
|
private let fontChoiceButton = UIButton(type: .system)
|
||||||
private let decreaseFontButton = UIButton(type: .system)
|
private let lineHeightButton = UIButton(type: .system)
|
||||||
|
private let displayTypeButton = UIButton(type: .system)
|
||||||
private let increaseFontButton = UIButton(type: .system)
|
private let themeStack = UIStackView()
|
||||||
|
|
||||||
private let fontChoiceControl = UISegmentedControl(items: RDEPUBReaderFontChoice.allCases.map(\.displayName))
|
|
||||||
|
|
||||||
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
|
|
||||||
|
|
||||||
private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
|
|
||||||
|
|
||||||
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
|
|
||||||
|
|
||||||
private let themeStackView: UIStackView = {
|
|
||||||
let stackView = UIStackView()
|
|
||||||
stackView.axis = .horizontal
|
|
||||||
stackView.distribution = .fillEqually
|
|
||||||
stackView.spacing = 12
|
|
||||||
return stackView
|
|
||||||
}()
|
|
||||||
|
|
||||||
private var themeButtons: [UIButton] = []
|
private var themeButtons: [UIButton] = []
|
||||||
|
|
||||||
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
|
||||||
|
|
||||||
private var currentConfiguration: RDEPUBReaderConfiguration
|
private var currentConfiguration: RDEPUBReaderConfiguration
|
||||||
|
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
||||||
|
private var hasNotifiedDismissal = false
|
||||||
|
|
||||||
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
||||||
self.currentConfiguration = configuration
|
currentConfiguration = configuration
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
brightnessSlider.value = Float(brightness)
|
brightnessSlider.value = Float(brightness)
|
||||||
title = "样式设置"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
@ -101,262 +64,266 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
setupNavigationItems()
|
|
||||||
setupViews()
|
setupViews()
|
||||||
syncControls()
|
syncControls()
|
||||||
applyTheme(currentConfiguration.theme)
|
applyTheme(currentConfiguration.theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNavigationItems() {
|
private func setupViews() {
|
||||||
let doneItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
|
view.accessibilityIdentifier = "epub.reader.settings.panel"
|
||||||
doneItem.accessibilityIdentifier = "epub.reader.settings.done"
|
view.layer.cornerRadius = 24
|
||||||
navigationItem.rightBarButtonItem = doneItem
|
view.layer.cornerCurve = .continuous
|
||||||
|
view.clipsToBounds = true
|
||||||
|
|
||||||
|
[handleView, closeButton, brightnessSlider, fontSizeSlider, fontSizeLabel,
|
||||||
|
fontChoiceButton, lineHeightButton, displayTypeButton, themeStack].forEach {
|
||||||
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview($0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupViews() {
|
handleView.layer.cornerRadius = 3
|
||||||
view.addSubview(scrollView)
|
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||||
scrollView.accessibilityIdentifier = "epub.reader.settings.scroll"
|
closeButton.accessibilityIdentifier = "epub.reader.settings.done"
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
closeButton.accessibilityLabel = "完成"
|
||||||
scrollView.addSubview(contentStack)
|
closeButton.addTarget(self, action: #selector(doneAction), for: .touchUpInside)
|
||||||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
|
||||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
|
|
||||||
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
|
|
||||||
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
|
|
||||||
contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20),
|
|
||||||
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24),
|
|
||||||
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
|
|
||||||
])
|
|
||||||
|
|
||||||
brightnessSlider.minimumValue = 0
|
brightnessSlider.minimumValue = 0
|
||||||
brightnessSlider.maximumValue = 1
|
brightnessSlider.maximumValue = 1
|
||||||
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
|
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
|
||||||
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
|
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
|
||||||
|
|
||||||
fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold)
|
fontSizeSlider.minimumValue = 12
|
||||||
fontValueLabel.textAlignment = .center
|
fontSizeSlider.maximumValue = 36
|
||||||
fontValueLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
|
fontSizeSlider.accessibilityIdentifier = "epub.reader.settings.font.size"
|
||||||
fontValueLabel.setContentHuggingPriority(.required, for: .horizontal)
|
fontSizeSlider.addTarget(self, action: #selector(fontSizeChanged(_:)), for: .valueChanged)
|
||||||
|
fontSizeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 14, weight: .semibold)
|
||||||
|
fontSizeLabel.textAlignment = .center
|
||||||
|
fontSizeLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
|
||||||
|
|
||||||
configureFontButton(decreaseFontButton, title: "A-")
|
configurePill(fontChoiceButton, title: "字体")
|
||||||
configureFontButton(increaseFontButton, title: "A+")
|
configurePill(lineHeightButton, title: "行距")
|
||||||
decreaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.decrease"
|
configurePill(displayTypeButton, title: "翻页方式")
|
||||||
increaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.increase"
|
fontChoiceButton.accessibilityIdentifier = "epub.reader.settings.font.choice"
|
||||||
decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside)
|
lineHeightButton.accessibilityIdentifier = "epub.reader.settings.lineHeight"
|
||||||
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
|
displayTypeButton.accessibilityIdentifier = "epub.reader.settings.displayType"
|
||||||
|
fontChoiceButton.addTarget(self, action: #selector(fontChoiceAction), for: .touchUpInside)
|
||||||
lineHeightControl.accessibilityIdentifier = "epub.reader.settings.lineHeight"
|
lineHeightButton.addTarget(self, action: #selector(lineHeightAction), for: .touchUpInside)
|
||||||
fontChoiceControl.accessibilityIdentifier = "epub.reader.settings.font.choice"
|
displayTypeButton.addTarget(self, action: #selector(displayTypeAction), for: .touchUpInside)
|
||||||
columnCountControl.accessibilityIdentifier = "epub.reader.settings.columns"
|
|
||||||
displayTypeControl.accessibilityIdentifier = "epub.reader.settings.displayType"
|
|
||||||
fontChoiceControl.addTarget(self, action: #selector(fontChoiceChanged(_:)), for: .valueChanged)
|
|
||||||
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
|
|
||||||
columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged)
|
|
||||||
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
|
|
||||||
|
|
||||||
|
themeStack.axis = .horizontal
|
||||||
|
themeStack.distribution = .fillEqually
|
||||||
|
themeStack.spacing = 14
|
||||||
ThemePreset.allCases.forEach { preset in
|
ThemePreset.allCases.forEach { preset in
|
||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
button.tag = preset.rawValue
|
button.tag = preset.rawValue
|
||||||
button.layer.cornerRadius = 18
|
|
||||||
button.layer.borderWidth = 1.5
|
|
||||||
button.backgroundColor = preset.theme.contentBackgroundColor
|
button.backgroundColor = preset.theme.contentBackgroundColor
|
||||||
|
button.layer.cornerRadius = 15
|
||||||
|
button.layer.borderWidth = 1
|
||||||
button.accessibilityLabel = preset.title
|
button.accessibilityLabel = preset.title
|
||||||
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
|
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
|
||||||
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
|
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
|
||||||
themeButtons.append(button)
|
themeButtons.append(button)
|
||||||
themeStackView.addArrangedSubview(button)
|
themeStack.addArrangedSubview(button)
|
||||||
|
button.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let brightnessLabel = makeLabel("亮度")
|
||||||
|
let fontSizeTitle = makeLabel("字体大小")
|
||||||
|
let smallA = makeLabel("A", font: 18)
|
||||||
|
let largeA = makeLabel("A", font: 25)
|
||||||
|
[brightnessLabel, fontSizeTitle, smallA, largeA].forEach(view.addSubview)
|
||||||
|
|
||||||
|
let selectorStack = UIStackView(arrangedSubviews: [fontChoiceButton, lineHeightButton, displayTypeButton])
|
||||||
|
selectorStack.axis = .horizontal
|
||||||
|
selectorStack.distribution = .fillEqually
|
||||||
|
selectorStack.spacing = 10
|
||||||
|
selectorStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(selectorStack)
|
||||||
|
|
||||||
|
let dividerOne = makeDivider()
|
||||||
|
let dividerTwo = makeDivider()
|
||||||
|
[dividerOne, dividerTwo].forEach(view.addSubview)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
button.heightAnchor.constraint(equalToConstant: 36)
|
handleView.topAnchor.constraint(equalTo: view.topAnchor, constant: 10),
|
||||||
|
handleView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
handleView.widthAnchor.constraint(equalToConstant: 42),
|
||||||
|
handleView.heightAnchor.constraint(equalToConstant: 5),
|
||||||
|
closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||||
|
closeButton.centerYAnchor.constraint(equalTo: handleView.centerYAnchor),
|
||||||
|
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
||||||
|
closeButton.heightAnchor.constraint(equalToConstant: 32),
|
||||||
|
|
||||||
|
brightnessLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
brightnessLabel.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 14),
|
||||||
|
brightnessSlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
brightnessSlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
brightnessSlider.topAnchor.constraint(equalTo: brightnessLabel.bottomAnchor, constant: 5),
|
||||||
|
dividerOne.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
dividerOne.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
dividerOne.topAnchor.constraint(equalTo: brightnessSlider.bottomAnchor, constant: 10),
|
||||||
|
|
||||||
|
fontSizeTitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
fontSizeTitle.topAnchor.constraint(equalTo: dividerOne.bottomAnchor, constant: 10),
|
||||||
|
smallA.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
smallA.centerYAnchor.constraint(equalTo: fontSizeSlider.centerYAnchor),
|
||||||
|
fontSizeSlider.leadingAnchor.constraint(equalTo: smallA.trailingAnchor, constant: 10),
|
||||||
|
fontSizeSlider.trailingAnchor.constraint(equalTo: largeA.leadingAnchor, constant: -10),
|
||||||
|
fontSizeSlider.topAnchor.constraint(equalTo: fontSizeTitle.bottomAnchor, constant: 4),
|
||||||
|
largeA.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
largeA.centerYAnchor.constraint(equalTo: fontSizeSlider.centerYAnchor),
|
||||||
|
fontSizeLabel.centerXAnchor.constraint(equalTo: fontSizeSlider.centerXAnchor),
|
||||||
|
fontSizeLabel.bottomAnchor.constraint(equalTo: fontSizeSlider.topAnchor, constant: -1),
|
||||||
|
dividerTwo.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
dividerTwo.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
dividerTwo.topAnchor.constraint(equalTo: fontSizeSlider.bottomAnchor, constant: 10),
|
||||||
|
|
||||||
|
selectorStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
selectorStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
selectorStack.topAnchor.constraint(equalTo: dividerTwo.bottomAnchor, constant: 12),
|
||||||
|
selectorStack.heightAnchor.constraint(equalToConstant: 42),
|
||||||
|
themeStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
||||||
|
themeStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
||||||
|
themeStack.topAnchor.constraint(equalTo: selectorStack.bottomAnchor, constant: 16),
|
||||||
|
themeStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -14)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
|
private func makeLabel(_ text: String, font: CGFloat = 13) -> UILabel {
|
||||||
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
|
let label = UILabel()
|
||||||
contentStack.addArrangedSubview(makeSection(title: "字体", content: fontChoiceControl))
|
label.text = text
|
||||||
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
|
label.font = UIFont.systemFont(ofSize: font, weight: font > 16 ? .regular : .semibold)
|
||||||
contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl))
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
|
return label
|
||||||
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeSection(title: String, content: UIView) -> UIView {
|
private func makeDivider() -> UIView {
|
||||||
let container = UIStackView()
|
let divider = UIView()
|
||||||
container.axis = .vertical
|
divider.translatesAutoresizingMaskIntoConstraints = false
|
||||||
container.spacing = 10
|
divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
|
||||||
|
return divider
|
||||||
let titleLabel = UILabel()
|
|
||||||
titleLabel.text = title
|
|
||||||
titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
|
||||||
|
|
||||||
content.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
container.addArrangedSubview(titleLabel)
|
|
||||||
container.addArrangedSubview(content)
|
|
||||||
return container
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeFontSizeRow() -> UIView {
|
private func configurePill(_ button: UIButton, title: String) {
|
||||||
let stackView = UIStackView(arrangedSubviews: [decreaseFontButton, fontValueLabel, increaseFontButton])
|
|
||||||
stackView.axis = .horizontal
|
|
||||||
stackView.alignment = .center
|
|
||||||
stackView.distribution = .fill
|
|
||||||
stackView.spacing = 12
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
decreaseFontButton.widthAnchor.constraint(equalToConstant: 64),
|
|
||||||
increaseFontButton.widthAnchor.constraint(equalToConstant: 64),
|
|
||||||
decreaseFontButton.heightAnchor.constraint(equalToConstant: 36),
|
|
||||||
increaseFontButton.heightAnchor.constraint(equalToConstant: 36)
|
|
||||||
])
|
|
||||||
return stackView
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configureFontButton(_ button: UIButton, title: String) {
|
|
||||||
button.setTitle(title, for: .normal)
|
button.setTitle(title, for: .normal)
|
||||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
|
button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
||||||
button.layer.cornerRadius = 18
|
button.layer.cornerRadius = 13
|
||||||
button.layer.borderWidth = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func syncControls() {
|
private func syncControls() {
|
||||||
fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
|
fontSizeSlider.value = Float(currentConfiguration.fontSize)
|
||||||
fontChoiceControl.selectedSegmentIndex = RDEPUBReaderFontChoice.allCases.firstIndex(of: currentConfiguration.fontChoice) ?? 0
|
fontSizeLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
|
||||||
|
updateSelectorTitles()
|
||||||
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
|
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light)
|
||||||
lineHeightControl.selectedSegmentIndex = lineHeightIndex
|
|
||||||
columnCountControl.selectedSegmentIndex = currentConfiguration.numberOfColumns > 1 ? 1 : 0
|
|
||||||
|
|
||||||
switch currentConfiguration.displayType {
|
|
||||||
case .pageCurl:
|
|
||||||
displayTypeControl.selectedSegmentIndex = 0
|
|
||||||
case .horizontalScroll:
|
|
||||||
displayTypeControl.selectedSegmentIndex = 1
|
|
||||||
case .verticalScroll:
|
|
||||||
displayTypeControl.selectedSegmentIndex = 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
|
private func updateSelectorTitles() {
|
||||||
updateThemeSelection(selectedPreset)
|
fontChoiceButton.setTitle("\(currentConfiguration.fontChoice.displayName) ›", for: .normal)
|
||||||
updateControlAccessibilityValues()
|
let lineIndex = lineHeightValues.enumerated().min {
|
||||||
|
abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple)
|
||||||
|
}?.offset ?? 1
|
||||||
|
fontChoiceButton.accessibilityValue = currentConfiguration.fontChoice.displayName
|
||||||
|
let lineTitles = ["紧凑", "标准", "宽松"]
|
||||||
|
lineHeightButton.setTitle("\(lineTitles[lineIndex]) ›", for: .normal)
|
||||||
|
lineHeightButton.accessibilityValue = lineTitles[lineIndex]
|
||||||
|
let displayTitle: String
|
||||||
|
switch currentConfiguration.displayType {
|
||||||
|
case .pageCurl: displayTitle = "仿真"
|
||||||
|
case .horizontalScroll: displayTitle = "横滑"
|
||||||
|
case .verticalScroll: displayTitle = "竖滑"
|
||||||
|
}
|
||||||
|
displayTypeButton.setTitle("\(displayTitle) ›", for: .normal)
|
||||||
|
displayTypeButton.accessibilityValue = displayTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTheme(_ theme: RDEPUBReaderTheme) {
|
private func applyTheme(_ theme: RDEPUBReaderTheme) {
|
||||||
view.backgroundColor = theme.contentBackgroundColor
|
view.backgroundColor = theme.toolBackgroundColor.withAlphaComponent(0.98)
|
||||||
scrollView.backgroundColor = theme.contentBackgroundColor
|
let textColor = theme.toolControlTextColor
|
||||||
contentStack.arrangedSubviews
|
handleView.backgroundColor = textColor.withAlphaComponent(0.45)
|
||||||
.compactMap { $0 as? UIStackView }
|
closeButton.tintColor = textColor
|
||||||
.flatMap { $0.arrangedSubviews }
|
view.subviews.compactMap { $0 as? UILabel }.forEach { $0.textColor = textColor }
|
||||||
.compactMap { $0 as? UILabel }
|
view.subviews.filter { $0 !== handleView && $0 !== closeButton && !($0 is UISlider) && !($0 is UIStackView) && !($0 is UILabel) }
|
||||||
.forEach { $0.textColor = theme.contentTextColor }
|
.forEach { $0.backgroundColor = textColor.withAlphaComponent(0.12) }
|
||||||
|
[fontChoiceButton, lineHeightButton, displayTypeButton].forEach {
|
||||||
fontValueLabel.textColor = theme.contentTextColor
|
$0.setTitleColor(textColor, for: .normal)
|
||||||
[decreaseFontButton, increaseFontButton].forEach { button in
|
$0.backgroundColor = textColor.withAlphaComponent(0.10)
|
||||||
button.setTitleColor(theme.toolControlTextColor, for: .normal)
|
|
||||||
button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor
|
|
||||||
button.backgroundColor = theme.toolBackgroundColor
|
|
||||||
}
|
}
|
||||||
|
brightnessSlider.minimumTrackTintColor = .systemBlue
|
||||||
[fontChoiceControl, lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
|
fontSizeSlider.minimumTrackTintColor = .systemBlue
|
||||||
control.backgroundColor = theme.toolBackgroundColor
|
brightnessSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
|
||||||
if #available(iOS 13.0, *) {
|
fontSizeSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
|
||||||
control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
|
|
||||||
} else {
|
|
||||||
control.tintColor = theme.toolControlTextColor
|
|
||||||
}
|
|
||||||
control.setTitleTextAttributes([.foregroundColor: theme.contentTextColor], for: .normal)
|
|
||||||
control.setTitleTextAttributes([.foregroundColor: theme.toolControlTextColor], for: .selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
|
||||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
|
||||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
|
||||||
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
|
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateThemeSelection(_ preset: ThemePreset) {
|
private func updateThemeSelection(_ preset: ThemePreset) {
|
||||||
themeButtons.forEach { button in
|
themeButtons.forEach { button in
|
||||||
let isSelected = button.tag == preset.rawValue
|
let selected = button.tag == preset.rawValue
|
||||||
button.layer.borderWidth = isSelected ? 2 : 1
|
button.layer.borderWidth = selected ? 2.5 : 1
|
||||||
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
|
button.layer.borderColor = selected ? UIColor.systemBlue.cgColor : UIColor.white.withAlphaComponent(0.28).cgColor
|
||||||
button.accessibilityValue = isSelected ? "selected" : "unselected"
|
button.accessibilityValue = selected ? "selected" : "unselected"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateControlAccessibilityValues() {
|
func notifyDismissalIfNeeded() {
|
||||||
fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex)
|
guard !hasNotifiedDismissal else { return }
|
||||||
lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex)
|
hasNotifiedDismissal = true
|
||||||
columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex)
|
onDismiss?()
|
||||||
displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func doneAction() {
|
@objc private func doneAction() {
|
||||||
dismiss(animated: true) { [weak self] in
|
notifyDismissalIfNeeded()
|
||||||
self?.onDismiss?()
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
|
@objc private func brightnessChanged(_ sender: UISlider) { onBrightnessChange?(CGFloat(sender.value)) }
|
||||||
|
|
||||||
|
@objc private func fontSizeChanged(_ sender: UISlider) {
|
||||||
|
let value = CGFloat(sender.value.rounded())
|
||||||
|
guard value != currentConfiguration.fontSize else { return }
|
||||||
|
currentConfiguration.fontSize = value
|
||||||
|
fontSizeLabel.text = String(Int(value))
|
||||||
|
onFontSizeChange?(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func brightnessChanged(_ slider: UISlider) {
|
@objc private func fontChoiceAction() {
|
||||||
onBrightnessChange?(CGFloat(slider.value))
|
let alert = UIAlertController(title: "字体", message: nil, preferredStyle: .actionSheet)
|
||||||
|
RDEPUBReaderFontChoice.allCases.forEach { choice in
|
||||||
|
alert.addAction(UIAlertAction(title: choice.displayName, style: .default) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.currentConfiguration.fontChoice = choice
|
||||||
|
self.updateSelectorTitles()
|
||||||
|
self.onFontChoiceChange?(choice)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func decreaseFontAction() {
|
@objc private func lineHeightAction() {
|
||||||
let nextValue = max(12, currentConfiguration.fontSize - 1)
|
let alert = UIAlertController(title: "行距", message: nil, preferredStyle: .actionSheet)
|
||||||
guard nextValue != currentConfiguration.fontSize else { return }
|
zip(["紧凑", "标准", "宽松"], lineHeightValues).forEach { title, value in
|
||||||
currentConfiguration.fontSize = nextValue
|
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||||
fontValueLabel.text = String(Int(nextValue.rounded()))
|
guard let self else { return }
|
||||||
onFontSizeChange?(nextValue)
|
self.currentConfiguration.lineHeightMultiple = value
|
||||||
|
self.updateSelectorTitles()
|
||||||
|
self.onLineHeightChange?(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func increaseFontAction() {
|
@objc private func displayTypeAction() {
|
||||||
let nextValue = min(36, currentConfiguration.fontSize + 1)
|
let alert = UIAlertController(title: "翻页方式", message: nil, preferredStyle: .actionSheet)
|
||||||
guard nextValue != currentConfiguration.fontSize else { return }
|
[("仿真", RDEpubReaderView.DisplayType.pageCurl), ("横滑", .horizontalScroll), ("竖滑", .verticalScroll)].forEach { title, type in
|
||||||
currentConfiguration.fontSize = nextValue
|
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||||
fontValueLabel.text = String(Int(nextValue.rounded()))
|
guard let self else { return }
|
||||||
onFontSizeChange?(nextValue)
|
self.currentConfiguration.displayType = type
|
||||||
|
self.updateSelectorTitles()
|
||||||
|
self.onDisplayTypeChange?(type)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
@objc private func fontChoiceChanged(_ control: UISegmentedControl) {
|
present(alert, animated: true)
|
||||||
let choices = RDEPUBReaderFontChoice.allCases
|
|
||||||
let index = max(0, min(control.selectedSegmentIndex, choices.count - 1))
|
|
||||||
let choice = choices[index]
|
|
||||||
guard choice != currentConfiguration.fontChoice else { return }
|
|
||||||
currentConfiguration.fontChoice = choice
|
|
||||||
updateControlAccessibilityValues()
|
|
||||||
onFontChoiceChange?(choice)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func lineHeightChanged(_ control: UISegmentedControl) {
|
|
||||||
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
|
|
||||||
let value = lineHeightValues[index]
|
|
||||||
currentConfiguration.lineHeightMultiple = value
|
|
||||||
updateControlAccessibilityValues()
|
|
||||||
onLineHeightChange?(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func columnCountChanged(_ control: UISegmentedControl) {
|
|
||||||
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
|
|
||||||
currentConfiguration.numberOfColumns = numberOfColumns
|
|
||||||
updateControlAccessibilityValues()
|
|
||||||
onColumnCountChange?(numberOfColumns)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func displayTypeChanged(_ control: UISegmentedControl) {
|
|
||||||
let displayType: RDEpubReaderView.DisplayType
|
|
||||||
switch control.selectedSegmentIndex {
|
|
||||||
case 1:
|
|
||||||
displayType = .horizontalScroll
|
|
||||||
case 2:
|
|
||||||
displayType = .verticalScroll
|
|
||||||
default:
|
|
||||||
displayType = .pageCurl
|
|
||||||
}
|
|
||||||
currentConfiguration.displayType = displayType
|
|
||||||
updateControlAccessibilityValues()
|
|
||||||
onDisplayTypeChange?(displayType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func themeButtonAction(_ sender: UIButton) {
|
@objc private func themeButtonAction(_ sender: UIButton) {
|
||||||
|
|||||||
@ -28,11 +28,19 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
|||||||
)
|
)
|
||||||
func textContentView(
|
func textContentView(
|
||||||
_ contentView: RDEPUBTextContentView,
|
_ contentView: RDEPUBTextContentView,
|
||||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
|
||||||
sourceRect: CGRect
|
highlight: RDEPUBHighlight
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RDEPUBExistingHighlightMenuAction {
|
||||||
|
case copy
|
||||||
|
case createUnderline
|
||||||
|
case deleteUnderline
|
||||||
|
case annotate
|
||||||
|
case deleteAnnotation
|
||||||
|
}
|
||||||
|
|
||||||
extension RDEPUBTextContentViewDelegate {
|
extension RDEPUBTextContentViewDelegate {
|
||||||
func textContentView(
|
func textContentView(
|
||||||
_ contentView: RDEPUBTextContentView,
|
_ contentView: RDEPUBTextContentView,
|
||||||
@ -60,6 +68,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
|
|
||||||
private var menuSelection: RDEPUBSelection?
|
private var menuSelection: RDEPUBSelection?
|
||||||
|
|
||||||
|
/// The underline whose compact text menu is currently visible. Keeping this
|
||||||
|
/// separate from `currentSelection` means a marked paragraph is still fully
|
||||||
|
/// selectable with a long press.
|
||||||
|
private var menuHighlight: RDEPUBHighlight?
|
||||||
|
|
||||||
|
private var menuUnderlineHighlight: RDEPUBHighlight?
|
||||||
|
|
||||||
|
private var menuAnnotationHighlight: RDEPUBHighlight?
|
||||||
|
|
||||||
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
||||||
// Stored as Any? to avoid @available on stored property restriction.
|
// Stored as Any? to avoid @available on stored property restriction.
|
||||||
private var _editMenuInteraction: Any?
|
private var _editMenuInteraction: Any?
|
||||||
@ -347,23 +364,52 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||||
action == #selector(rd_copy(_:))
|
if menuHighlight != nil {
|
||||||
|
return action == #selector(rd_copy(_:))
|
||||||
|
|| action == #selector(rd_highlight(_:))
|
||||||
|
|| action == #selector(rd_deleteUnderline(_:))
|
||||||
|
|| action == #selector(rd_annotate(_:))
|
||||||
|
|| action == #selector(rd_deleteAnnotation(_:))
|
||||||
|
}
|
||||||
|
return action == #selector(rd_copy(_:))
|
||||||
|| action == #selector(rd_highlight(_:))
|
|| action == #selector(rd_highlight(_:))
|
||||||
|| action == #selector(rd_annotate(_:))
|
|| action == #selector(rd_annotate(_:))
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func rd_copy(_ sender: Any?) {
|
@objc func rd_copy(_ sender: Any?) {
|
||||||
|
if let highlight = menuHighlight {
|
||||||
|
performHighlightMenuAction(.copy, highlight: highlight)
|
||||||
|
return
|
||||||
|
}
|
||||||
performSelectionAction(.copy)
|
performSelectionAction(.copy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func rd_highlight(_ sender: Any?) {
|
@objc func rd_highlight(_ sender: Any?) {
|
||||||
|
if let highlight = menuAnnotationHighlight {
|
||||||
|
performHighlightMenuAction(.createUnderline, highlight: highlight)
|
||||||
|
return
|
||||||
|
}
|
||||||
performSelectionAction(.highlight)
|
performSelectionAction(.highlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func rd_annotate(_ sender: Any?) {
|
@objc func rd_annotate(_ sender: Any?) {
|
||||||
|
if let highlight = menuHighlight {
|
||||||
|
performHighlightMenuAction(.annotate, highlight: highlight)
|
||||||
|
return
|
||||||
|
}
|
||||||
performSelectionAction(.annotate)
|
performSelectionAction(.annotate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func rd_deleteUnderline(_ sender: Any?) {
|
||||||
|
guard let highlight = menuUnderlineHighlight else { return }
|
||||||
|
performHighlightMenuAction(.deleteUnderline, highlight: highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func rd_deleteAnnotation(_ sender: Any?) {
|
||||||
|
guard let highlight = menuAnnotationHighlight else { return }
|
||||||
|
performHighlightMenuAction(.deleteAnnotation, highlight: highlight)
|
||||||
|
}
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
@ -855,51 +901,85 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let highlight = highlight(at: point),
|
let highlights = highlights(at: point)
|
||||||
|
guard let highlight = highlights.first,
|
||||||
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
||||||
RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
|
RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
|
||||||
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)")
|
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightIds=\(highlights.map(\.id).joined(separator: ","))")
|
||||||
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
|
showHighlightMenu(for: highlights, sourceRect: sourceRect)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showHighlightMenu(for highlights: [RDEPUBHighlight], sourceRect: CGRect) {
|
||||||
|
// A short tap opens the compact system menu. Long-press selection is
|
||||||
|
// intentionally handled by the separate recognizer, which the tap
|
||||||
|
// recognizer already waits on, so text inside a marking remains
|
||||||
|
// selectable.
|
||||||
|
let underline = highlights.first { !$0.isAnnotationOnly }
|
||||||
|
let annotation = highlights.first { $0.isAnnotationOnly }
|
||||||
|
?? highlights.first { $0.hasNote }
|
||||||
|
menuHighlight = annotation ?? underline
|
||||||
|
menuUnderlineHighlight = underline
|
||||||
|
menuAnnotationHighlight = annotation
|
||||||
|
guard menuHighlight != nil else { return }
|
||||||
|
becomeFirstResponder()
|
||||||
|
let menuController = UIMenuController.shared
|
||||||
|
var items = [UIMenuItem(title: "复制", action: #selector(rd_copy(_:)))]
|
||||||
|
if underline != nil {
|
||||||
|
items.append(UIMenuItem(title: "删除划线", action: #selector(rd_deleteUnderline(_:))))
|
||||||
|
} else if annotation != nil {
|
||||||
|
items.append(UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))))
|
||||||
|
}
|
||||||
|
if let annotation, annotation.hasNote {
|
||||||
|
items.append(UIMenuItem(title: "删除注释", action: #selector(rd_deleteAnnotation(_:))))
|
||||||
|
} else if let underline, !underline.hasNote {
|
||||||
|
items.append(UIMenuItem(title: "注释", action: #selector(rd_annotate(_:))))
|
||||||
|
}
|
||||||
|
menuController.menuItems = items
|
||||||
|
menuController.setTargetRect(sourceRect, in: self)
|
||||||
|
menuController.setMenuVisible(true, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performHighlightMenuAction(
|
||||||
|
_ action: RDEPUBExistingHighlightMenuAction,
|
||||||
|
highlight: RDEPUBHighlight
|
||||||
|
) {
|
||||||
|
menuHighlight = nil
|
||||||
|
menuUnderlineHighlight = nil
|
||||||
|
menuAnnotationHighlight = nil
|
||||||
|
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||||
|
delegate?.textContentView(self, didRequestHighlightMenuAction: action, highlight: highlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showSelectionMenuIfNeeded() {
|
private func showSelectionMenuIfNeeded() {
|
||||||
|
menuHighlight = nil
|
||||||
|
menuUnderlineHighlight = nil
|
||||||
|
menuAnnotationHighlight = nil
|
||||||
guard currentSelection != nil,
|
guard currentSelection != nil,
|
||||||
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
||||||
!targetRect.isEmpty else {
|
!targetRect.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
|
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
|
||||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
|
||||||
becomeFirstResponder()
|
|
||||||
let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
|
|
||||||
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
|
|
||||||
DispatchQueue.main.async { [weak self, weak interaction] in
|
|
||||||
guard let self, self.currentSelection != nil else { return }
|
|
||||||
interaction?.presentEditMenu(with: config)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
becomeFirstResponder()
|
becomeFirstResponder()
|
||||||
let menuController = UIMenuController.shared
|
let menuController = UIMenuController.shared
|
||||||
menuController.menuItems = [
|
menuController.menuItems = [
|
||||||
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
|
UIMenuItem(title: "复制", action: #selector(rd_copy(_:))),
|
||||||
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
|
UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))),
|
||||||
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
|
UIMenuItem(title: "注释", action: #selector(rd_annotate(_:)))
|
||||||
]
|
]
|
||||||
menuController.setTargetRect(resolvedTargetRect, in: self)
|
menuController.setTargetRect(resolvedTargetRect, in: self)
|
||||||
menuController.setMenuVisible(true, animated: true)
|
menuController.setMenuVisible(true, animated: true)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func hideSelectionMenu() {
|
private func hideSelectionMenu() {
|
||||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
menuHighlight = nil
|
||||||
interaction.dismissMenu()
|
menuUnderlineHighlight = nil
|
||||||
} else {
|
menuAnnotationHighlight = nil
|
||||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func updateSelectionLoupe(for point: CGPoint) {
|
private func updateSelectionLoupe(for point: CGPoint) {
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
@ -937,7 +1017,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
||||||
guard let page = currentPage else { return nil }
|
highlights(at: point).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func highlights(at point: CGPoint) -> [RDEPUBHighlight] {
|
||||||
|
guard let page = currentPage else { return [] }
|
||||||
let matches = currentHighlights.filter { highlight in
|
let matches = currentHighlights.filter { highlight in
|
||||||
guard highlight.location.href == page.href,
|
guard highlight.location.href == page.href,
|
||||||
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
||||||
@ -952,11 +1036,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
return matches.sorted { lhs, rhs in
|
return matches.sorted { lhs, rhs in
|
||||||
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
|
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
|
||||||
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
|
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
|
||||||
if lhs.hasNote != rhs.hasNote {
|
if lhsRange != rhsRange {
|
||||||
return lhs.hasNote && !rhs.hasNote
|
// In an overlap, the narrower marking is the one the reader
|
||||||
}
|
// most likely meant to touch (e.g. underline A inside note B).
|
||||||
return lhsRange < rhsRange
|
return lhsRange < rhsRange
|
||||||
}.first
|
}
|
||||||
|
return lhs.createdAt > rhs.createdAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
|
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
|
||||||
@ -1133,9 +1219,9 @@ extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
|||||||
suggestedActions: [UIMenuElement]
|
suggestedActions: [UIMenuElement]
|
||||||
) -> UIMenu? {
|
) -> UIMenu? {
|
||||||
UIMenu(children: [
|
UIMenu(children: [
|
||||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
UICommand(title: "复制", action: #selector(rd_copy(_:))),
|
||||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
UICommand(title: "划线", action: #selector(rd_highlight(_:))),
|
||||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
UICommand(title: "注释", action: #selector(rd_annotate(_:)))
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -127,14 +127,19 @@ final class RDEpubReaderPreloadController {
|
|||||||
return preloaded
|
return preloaded
|
||||||
}
|
}
|
||||||
|
|
||||||
func updatePageVisibility(visiblePageNumbers: Set<Int>) {
|
func updatePageVisibility(visibleViewIdentifiers: Set<ObjectIdentifier>) {
|
||||||
var notifiedViews = Set<ObjectIdentifier>()
|
|
||||||
let cachedViews = pageCurlCachedViews.merging(preloadedPageViews) { current, _ in current }
|
let cachedViews = pageCurlCachedViews.merging(preloadedPageViews) { current, _ in current }
|
||||||
for (pageNumber, view) in cachedViews {
|
var notifiedViews = Set<ObjectIdentifier>()
|
||||||
|
|
||||||
|
// Cache entries are never the authority for activation: UIPageViewController
|
||||||
|
// can create two views for the same page number. Keep every non-winning
|
||||||
|
// instance suspended; the reader activates only its actually attached view.
|
||||||
|
for view in cachedViews.values {
|
||||||
let identifier = ObjectIdentifier(view)
|
let identifier = ObjectIdentifier(view)
|
||||||
guard notifiedViews.insert(identifier).inserted else { continue }
|
guard !visibleViewIdentifiers.contains(identifier),
|
||||||
|
notifiedViews.insert(identifier).inserted else { continue }
|
||||||
(view as? RDEpubReaderPageVisibilityHandling)?
|
(view as? RDEpubReaderPageVisibilityHandling)?
|
||||||
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageNumber))
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,7 +152,7 @@ final class RDEpubReaderPreloadController {
|
|||||||
) {
|
) {
|
||||||
refreshCacheSignatureIfNeeded(environment)
|
refreshCacheSignatureIfNeeded(environment)
|
||||||
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment)
|
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment)
|
||||||
let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment))
|
let keepSet = Set(targets).union(spreadPageNumbers(startingAt: pageNum, environment: environment))
|
||||||
trimCachedPageViews(keeping: keepSet)
|
trimCachedPageViews(keeping: keepSet)
|
||||||
guard !targets.isEmpty else { return }
|
guard !targets.isEmpty else { return }
|
||||||
|
|
||||||
@ -194,6 +199,8 @@ final class RDEpubReaderPreloadController {
|
|||||||
"cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))"
|
"cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
||||||
if shouldCacheContentView {
|
if shouldCacheContentView {
|
||||||
preloadedPageViews[targetPage] = contentView
|
preloadedPageViews[targetPage] = contentView
|
||||||
@ -309,10 +316,6 @@ final class RDEpubReaderPreloadController {
|
|||||||
).left
|
).left
|
||||||
}
|
}
|
||||||
|
|
||||||
private func visiblePageNumbers(for anchorPage: Int, environment: Environment) -> Set<Int> {
|
|
||||||
spreadPageNumbers(startingAt: anchorPage, environment: environment)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func detachedReusablePageView(for pageNum: Int) -> UIView? {
|
private func detachedReusablePageView(for pageNum: Int) -> UIView? {
|
||||||
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
|
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
|
||||||
preloaded.removeFromSuperview()
|
preloaded.removeFromSuperview()
|
||||||
@ -351,6 +354,8 @@ final class RDEpubReaderPreloadController {
|
|||||||
// 的缓存重新呈现,提前拆除会让翻页露出空白页。这类视图交由持有方
|
// 的缓存重新呈现,提前拆除会让翻页露出空白页。这类视图交由持有方
|
||||||
// 释放后经 deinit 自然回收(弱代理已保证无保留环)。
|
// 释放后经 deinit 自然回收(弱代理已保证无保留环)。
|
||||||
let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView
|
let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView
|
||||||
|
(view as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
view.removeFromSuperview()
|
view.removeFromSuperview()
|
||||||
if !isHeldOutsidePreloadHost {
|
if !isHeldOutsidePreloadHost {
|
||||||
(view as? RDEpubReaderPageResourceReleasing)?.releaseResources()
|
(view as? RDEpubReaderPageResourceReleasing)?.releaseResources()
|
||||||
|
|||||||
@ -11,8 +11,11 @@ extension RDEpubReaderView: UICollectionViewDataSource, RDEpubReaderFlowLayoutDe
|
|||||||
let reusableView = cell.containerView ?? preloadedView
|
let reusableView = cell.containerView ?? preloadedView
|
||||||
let contentView = contentViewForPage(indexPath.row, reusableView: reusableView)
|
let contentView = contentViewForPage(indexPath.row, reusableView: reusableView)
|
||||||
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(indexPath.row))
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
cell.containerView = contentView
|
cell.containerView = contentView
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.updatePageContentVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
|
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
|
||||||
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
|
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||||
|
|||||||
@ -96,7 +96,28 @@ extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewController
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||||
let resolvedPage = (pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.pageNum ?? -1
|
let visibleControllers = pageViewController.viewControllers ?? []
|
||||||
|
let visibleControllerIdentifiers = Set(visibleControllers.map(ObjectIdentifier.init))
|
||||||
|
let transitionControllers = previousViewControllers + [
|
||||||
|
willPreviousTransitionToViewController,
|
||||||
|
willNextTransitionToViewController,
|
||||||
|
willTransitionToViewController
|
||||||
|
].compactMap { $0 }
|
||||||
|
var hiddenContentViews = Set<ObjectIdentifier>()
|
||||||
|
|
||||||
|
// UIPageViewController can ask its data source for a second instance of
|
||||||
|
// the same page and leave the first one outside our cache dictionaries.
|
||||||
|
// Explicitly stop every controller that did not win the transition so
|
||||||
|
// an orphaned Hype WebView cannot keep narrating in the background.
|
||||||
|
for controller in transitionControllers where !visibleControllerIdentifiers.contains(ObjectIdentifier(controller)) {
|
||||||
|
guard let childController = controller as? RDEpubReaderPageChildViewController,
|
||||||
|
let contentView = childController.contentView else { continue }
|
||||||
|
let identifier = ObjectIdentifier(contentView)
|
||||||
|
guard hiddenContentViews.insert(identifier).inserted else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
|
|
||||||
if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController {
|
if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController {
|
||||||
let pn = firstVC.pageNum
|
let pn = firstVC.pageNum
|
||||||
if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum {
|
if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum {
|
||||||
@ -105,6 +126,14 @@ extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On cancellation currentPage does not change, so its didSet cannot
|
||||||
|
// restore visibility. Reassert the actual controllers in both paths.
|
||||||
|
for case let childController as RDEpubReaderPageChildViewController in visibleControllers {
|
||||||
|
guard let contentView = childController.contentView else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(childController.pageNum))
|
||||||
|
}
|
||||||
|
|
||||||
predictedPageDirection = nil
|
predictedPageDirection = nil
|
||||||
finishPageCurlTransition()
|
finishPageCurlTransition()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -527,10 +527,13 @@ public class RDEpubReaderView: UIView {
|
|||||||
let pageView = preloadController.pageViewForDisplay(
|
let pageView = preloadController.pageViewForDisplay(
|
||||||
pageNum: pageNum,
|
pageNum: pageNum,
|
||||||
environment: preloadEnvironment,
|
environment: preloadEnvironment,
|
||||||
contentViewProvider: pageContentViewForDisplay(pageNum:reusableView:)
|
contentViewProvider: contentViewForPage(_:reusableView:)
|
||||||
)
|
)
|
||||||
|
// A data-source request is only a candidate. UIPageViewController may
|
||||||
|
// ask for duplicate instances of the current page; activation happens
|
||||||
|
// after the winning controller is actually installed.
|
||||||
(pageView as? RDEpubReaderPageVisibilityHandling)?
|
(pageView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(pageNum))
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
return pageView
|
return pageView
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -551,10 +554,6 @@ public class RDEpubReaderView: UIView {
|
|||||||
return contentViewForPage(pageNum, reusableView: reusableView)
|
return contentViewForPage(pageNum, reusableView: reusableView)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pageContentViewForDisplay(pageNum: Int, reusableView: UIView?) -> UIView? {
|
|
||||||
return contentViewForPage(pageNum, reusableView: reusableView)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isReaderPageVisible(_ pageNum: Int) -> Bool {
|
func isReaderPageVisible(_ pageNum: Int) -> Bool {
|
||||||
guard currentPage >= 0 else { return pageNum == 0 }
|
guard currentPage >= 0 else { return pageNum == 0 }
|
||||||
if landscapeDualPageEnabled && isLandscape {
|
if landscapeDualPageEnabled && isLandscape {
|
||||||
@ -564,7 +563,7 @@ public class RDEpubReaderView: UIView {
|
|||||||
return pageNum == currentPage
|
return pageNum == currentPage
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updatePageContentVisibility() {
|
func updatePageContentVisibility() {
|
||||||
var visiblePageNumbers = Set<Int>()
|
var visiblePageNumbers = Set<Int>()
|
||||||
if currentPage >= 0 {
|
if currentPage >= 0 {
|
||||||
visiblePageNumbers.insert(currentPage)
|
visiblePageNumbers.insert(currentPage)
|
||||||
@ -574,20 +573,57 @@ public class RDEpubReaderView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
preloadController.updatePageVisibility(visiblePageNumbers: visiblePageNumbers)
|
let pageControllers: [RDEpubReaderPageChildViewController]
|
||||||
|
let visibleCellContents: [(pageNumber: Int, contentView: UIView)]
|
||||||
for viewController in pageViewController.viewControllers ?? [] {
|
if currentDisplayType == .pageCurl {
|
||||||
guard let pageController = viewController as? RDEpubReaderPageChildViewController,
|
pageControllers = (pageViewController.viewControllers ?? []).compactMap {
|
||||||
let contentView = pageController.contentView else { continue }
|
$0 as? RDEpubReaderPageChildViewController
|
||||||
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
}
|
||||||
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageController.pageNum))
|
visibleCellContents = []
|
||||||
|
} else {
|
||||||
|
pageControllers = []
|
||||||
|
visibleCellContents = collectionView.indexPathsForVisibleItems.compactMap { indexPath in
|
||||||
|
guard let cell = collectionView.cellForItem(at: indexPath) as? RDEpubReaderContentCell,
|
||||||
|
let contentView = cell.containerView else { return nil }
|
||||||
|
return (indexPath.item, contentView)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for indexPath in collectionView.indexPathsForVisibleItems {
|
for pageController in pageControllers where !visiblePageNumbers.contains(pageController.pageNum) {
|
||||||
guard let cell = collectionView.cellForItem(at: indexPath) as? RDEpubReaderContentCell,
|
guard let contentView = pageController.contentView else { continue }
|
||||||
let contentView = cell.containerView else { continue }
|
|
||||||
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(indexPath.item))
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in visibleCellContents where !visiblePageNumbers.contains(entry.pageNumber) {
|
||||||
|
(entry.contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspend every cached non-winner before the attached winner below is
|
||||||
|
// activated. Object identity matters because a page number can have
|
||||||
|
// multiple UIPageViewController candidates.
|
||||||
|
let visibleViewIdentifiers = Set(
|
||||||
|
pageControllers.compactMap { pageController -> ObjectIdentifier? in
|
||||||
|
guard visiblePageNumbers.contains(pageController.pageNum),
|
||||||
|
let contentView = pageController.contentView else { return nil }
|
||||||
|
return ObjectIdentifier(contentView)
|
||||||
|
} + visibleCellContents.compactMap { entry -> ObjectIdentifier? in
|
||||||
|
guard visiblePageNumbers.contains(entry.pageNumber) else { return nil }
|
||||||
|
return ObjectIdentifier(entry.contentView)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
preloadController.updatePageVisibility(visibleViewIdentifiers: visibleViewIdentifiers)
|
||||||
|
|
||||||
|
for pageController in pageControllers where visiblePageNumbers.contains(pageController.pageNum) {
|
||||||
|
guard let contentView = pageController.contentView else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in visibleCellContents where visiblePageNumbers.contains(entry.pageNumber) {
|
||||||
|
(entry.contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -629,6 +665,11 @@ public class RDEpubReaderView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func detachPageViewControllerIfNeeded() {
|
private func detachPageViewControllerIfNeeded() {
|
||||||
|
for case let childController as RDEpubReaderPageChildViewController in pageViewController.viewControllers ?? [] {
|
||||||
|
guard let contentView = childController.contentView else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
if pageViewController.parent != nil {
|
if pageViewController.parent != nil {
|
||||||
pageViewController.willMove(toParent: nil)
|
pageViewController.willMove(toParent: nil)
|
||||||
}
|
}
|
||||||
@ -882,6 +923,17 @@ public class RDEpubReaderView: UIView {
|
|||||||
}
|
}
|
||||||
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
||||||
attachPageViewControllerIfNeeded()
|
attachPageViewControllerIfNeeded()
|
||||||
|
|
||||||
|
// Programmatic setViewControllers transitions do not call the
|
||||||
|
// UIPageViewControllerDelegate didFinishAnimating callback. Stop
|
||||||
|
// the outgoing controllers explicitly before replacing them, even
|
||||||
|
// when a duplicate page instance has fallen out of our cache map.
|
||||||
|
for case let childController as RDEpubReaderPageChildViewController in pageViewController.viewControllers ?? [] {
|
||||||
|
guard let contentView = childController.contentView else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
|
|
||||||
if isDualPage {
|
if isDualPage {
|
||||||
let pair = dualPagePair(for: safePageNum)
|
let pair = dualPagePair(for: safePageNum)
|
||||||
let leftContent = pageViewForDisplay(pageNum: pair.left)
|
let leftContent = pageViewForDisplay(pageNum: pair.left)
|
||||||
@ -903,7 +955,11 @@ public class RDEpubReaderView: UIView {
|
|||||||
self.finishPageCurlTransition()
|
self.finishPageCurlTransition()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let pageChanged = currentPage != pair.left
|
||||||
currentPage = pair.left
|
currentPage = pair.left
|
||||||
|
if !pageChanged {
|
||||||
|
updatePageContentVisibility()
|
||||||
|
}
|
||||||
primePageCache(around: pair.left, preferredForward: predictedPageDirection)
|
primePageCache(around: pair.left, preferredForward: predictedPageDirection)
|
||||||
} else {
|
} else {
|
||||||
let contentView = pageViewForDisplay(pageNum: safePageNum)
|
let contentView = pageViewForDisplay(pageNum: safePageNum)
|
||||||
@ -913,7 +969,11 @@ public class RDEpubReaderView: UIView {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.finishPageCurlTransition()
|
self.finishPageCurlTransition()
|
||||||
}
|
}
|
||||||
|
let pageChanged = currentPage != safePageNum
|
||||||
currentPage = safePageNum
|
currentPage = safePageNum
|
||||||
|
if !pageChanged {
|
||||||
|
updatePageContentVisibility()
|
||||||
|
}
|
||||||
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@ -921,7 +981,11 @@ public class RDEpubReaderView: UIView {
|
|||||||
collectionView.layoutIfNeeded()
|
collectionView.layoutIfNeeded()
|
||||||
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
||||||
collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated)
|
collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated)
|
||||||
|
let pageChanged = currentPage != safePageNum
|
||||||
currentPage = safePageNum
|
currentPage = safePageNum
|
||||||
|
if !pageChanged {
|
||||||
|
updatePageContentVisibility()
|
||||||
|
}
|
||||||
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user