feat: chapter runtime refactoring and related updates
- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator - Update EPUB core: parser, reading session, JS bridge, navigator layout - Update reader controller: data source, location resolution, persistence - Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator - Remove deprecated navigation state machine and pagination state - Update text rendering: book cache, HTML normalizer - Update UI: text content view, dark image adjuster, text selection controller - Update settings and reader configuration - Add CODE_REVIEW.md and AUDIT_FINAL.md documentation - Update pod dependencies (remove SSAlertSwift, SnapKit) - Update podspec and pod configuration files Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,20 @@ import DTCoreText
|
||||
|
||||
enum RDEPUBDarkImageAdjuster {
|
||||
|
||||
private static let imageCache = NSCache<NSString, UIImage>()
|
||||
private static let imageCache: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
cache.countLimit = 100
|
||||
cache.totalCostLimit = 52_428_800 // 50 MB
|
||||
return cache
|
||||
}()
|
||||
|
||||
private static func imageCost(of image: UIImage) -> Int {
|
||||
let scale = image.scale
|
||||
let width = Int(image.size.width * scale)
|
||||
let height = Int(image.size.height * scale)
|
||||
// 4 bytes per pixel (RGBA)
|
||||
return width * height * 4
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
@@ -84,7 +97,7 @@ enum RDEPUBDarkImageAdjuster {
|
||||
context.cgContext.setBlendMode(.sourceAtop)
|
||||
context.fill(CGRect(origin: .zero, size: image.size))
|
||||
}
|
||||
imageCache.setObject(adjusted, forKey: cacheKey)
|
||||
imageCache.setObject(adjusted, forKey: cacheKey, cost: imageCost(of: adjusted))
|
||||
return adjusted
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@ extension RDEPUBTextContentViewDelegate {
|
||||
|
||||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
|
||||
|
||||
private static let pageNumberTrailingPadding: CGFloat = 4
|
||||
private static let pageNumberFooterPadding: CGFloat = 8
|
||||
private static let pageNumberReservedHeight = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
|
||||
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
@@ -56,6 +60,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
|
||||
private var menuSelection: RDEPUBSelection?
|
||||
|
||||
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
||||
// Stored as Any? to avoid @available on stored property restriction.
|
||||
private var _editMenuInteraction: Any?
|
||||
|
||||
private let selectionLoupeView = RDEPUBSelectionLoupeView()
|
||||
|
||||
private var currentHighlights: [RDEPUBHighlight] = []
|
||||
@@ -241,6 +249,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
|
||||
selectionLoupeView.isHidden = true
|
||||
|
||||
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
|
||||
if #available(iOS 16.0, *) {
|
||||
let interaction = UIEditMenuInteraction(delegate: self)
|
||||
addInteraction(interaction)
|
||||
self._editMenuInteraction = interaction
|
||||
}
|
||||
|
||||
selectionController.onSelectionChanged = { [weak self] selection in
|
||||
guard let self else { return }
|
||||
self.currentSelection = selection
|
||||
@@ -322,10 +337,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
overlayView.frame = bounds.inset(by: contentInsets)
|
||||
coverImageView.frame = bounds.inset(by: contentInsets)
|
||||
|
||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||
let contentRect = bounds.inset(by: contentInsets)
|
||||
let labelSize = pageNumberLabel.sizeThatFits(
|
||||
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
|
||||
)
|
||||
let footerOriginY = bounds.maxY - contentInsets.bottom
|
||||
let footerVerticalInset = max((contentInsets.bottom - labelSize.height) / 2, 0)
|
||||
pageNumberLabel.frame = CGRect(
|
||||
x: bounds.width - labelSize.width - 24,
|
||||
y: bounds.height - labelSize.height - 20,
|
||||
x: bounds.maxX - contentInsets.right - labelSize.width - Self.pageNumberTrailingPadding,
|
||||
y: footerOriginY + footerVerticalInset,
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
@@ -352,7 +372,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
currentHighlights = highlights
|
||||
currentSearchState = searchState
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
contentInsets = Self.safeContentInsets(
|
||||
configuration: configuration,
|
||||
safeAreaInsets: safeAreaInsets
|
||||
)
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
@@ -434,7 +457,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
currentHighlights = []
|
||||
currentSearchState = nil
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
contentInsets = Self.safeContentInsets(
|
||||
configuration: configuration,
|
||||
safeAreaInsets: safeAreaInsets
|
||||
)
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
@@ -471,7 +497,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
updateSelectionPanAvailability()
|
||||
updateViewInteractionAvailability()
|
||||
updateAccessibilityDecorationSummary()
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
hideSelectionMenu()
|
||||
}
|
||||
|
||||
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
|
||||
@@ -562,25 +588,47 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
|
||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||
let content = NSMutableAttributedString(attributedString: page.content)
|
||||
guard shouldNormalizeContinuationParagraph(for: page) else { return content }
|
||||
|
||||
guard content.length > 0 else { return content }
|
||||
let text = content.string as NSString
|
||||
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
|
||||
guard firstParagraphRange.length > 0 else { return content }
|
||||
guard !firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
|
||||
|
||||
if shouldNormalizeLeadingParagraphSpacing(for: page) {
|
||||
let leadingRange = firstNonWhitespaceParagraphRange(in: content) ?? firstParagraphRange
|
||||
updateParagraphStyle(in: content, range: leadingRange) { style in
|
||||
style.paragraphSpacingBefore = 0
|
||||
}
|
||||
}
|
||||
|
||||
guard shouldNormalizeContinuationParagraph(for: page),
|
||||
!firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
|
||||
return content
|
||||
}
|
||||
|
||||
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
|
||||
guard let style = value as? NSParagraphStyle else { return }
|
||||
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
||||
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
|
||||
mutableStyle.paragraphSpacingBefore = 0
|
||||
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
|
||||
updateParagraphStyle(in: content, range: firstParagraphRange) { style in
|
||||
style.firstLineHeadIndent = style.headIndent
|
||||
style.paragraphSpacingBefore = 0
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
private func shouldNormalizeLeadingParagraphSpacing(for page: RDEPUBTextPage) -> Bool {
|
||||
page.pageStartOffset == 0
|
||||
}
|
||||
|
||||
private func firstNonWhitespaceParagraphRange(in content: NSAttributedString) -> NSRange? {
|
||||
let text = content.string as NSString
|
||||
var index = 0
|
||||
while index < text.length {
|
||||
guard let scalar = UnicodeScalar(text.character(at: index)) else { break }
|
||||
if !CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
return text.paragraphRange(for: NSRange(location: index, length: 0))
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
|
||||
let pageStart = page.pageStartOffset
|
||||
guard pageStart > 0, pageStart < page.chapterContent.length else { return false }
|
||||
@@ -589,6 +637,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
return !CharacterSet.newlines.contains(previousScalar)
|
||||
}
|
||||
|
||||
private func updateParagraphStyle(
|
||||
in content: NSMutableAttributedString,
|
||||
range: NSRange,
|
||||
transform: (NSMutableParagraphStyle) -> Void
|
||||
) {
|
||||
content.enumerateAttribute(.paragraphStyle, in: range) { value, attributeRange, _ in
|
||||
guard let style = value as? NSParagraphStyle else { return }
|
||||
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
||||
transform(mutableStyle)
|
||||
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: attributeRange)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func firstParagraphUsesNonLeadingAlignment(
|
||||
in content: NSAttributedString,
|
||||
range: NSRange
|
||||
@@ -743,19 +805,29 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
!targetRect.isEmpty else {
|
||||
return
|
||||
}
|
||||
becomeFirstResponder()
|
||||
let menuController = UIMenuController.shared
|
||||
menuController.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
]
|
||||
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
||||
let anchor = CGPoint(x: targetRect.midX, y: targetRect.midY)
|
||||
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
|
||||
interaction.presentEditMenu(with: config)
|
||||
} else {
|
||||
becomeFirstResponder()
|
||||
let menuController = UIMenuController.shared
|
||||
menuController.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
]
|
||||
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func hideSelectionMenu() {
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
||||
interaction.dismissMenu()
|
||||
} else {
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelectionLoupe(for point: CGPoint) {
|
||||
@@ -954,3 +1026,51 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
menuFor configuration: UIEditMenuConfiguration
|
||||
) -> UIMenu {
|
||||
UIMenu(children: [
|
||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
])
|
||||
}
|
||||
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
targetRectFor configuration: UIEditMenuConfiguration
|
||||
) -> CGRect {
|
||||
if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
||||
!targetRect.isEmpty {
|
||||
return targetRect
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Safe Content Insets
|
||||
|
||||
extension RDEPUBTextContentView {
|
||||
|
||||
/// Computes content insets that account for safe areas (Dynamic Island, home indicator).
|
||||
/// Uses the larger of safeAreaInsets and reflowableContentInsets for each edge,
|
||||
/// ensuring content is never hidden under the Dynamic Island or home indicator area.
|
||||
private static func safeContentInsets(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
safeAreaInsets: UIEdgeInsets
|
||||
) -> UIEdgeInsets {
|
||||
let configInsets = configuration.reflowableContentInsets
|
||||
return UIEdgeInsets(
|
||||
top: max(configInsets.top, safeAreaInsets.top),
|
||||
left: max(configInsets.left, safeAreaInsets.left),
|
||||
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
|
||||
right: max(configInsets.right, safeAreaInsets.right)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,6 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
selectionEndIndex = NSNotFound
|
||||
setInteractionState(.idle)
|
||||
renderView?.selectionRects = []
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
onSelectionChanged?(nil)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user